fix: stop dropped files from inserting their path into the message body

This commit is contained in:
Linus Rath
2026-10-13 16:58:19 +02:00
parent e0f60c0946
commit 3138c2bd04
2 changed files with 83 additions and 5 deletions
@@ -0,0 +1,75 @@
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { RichTextEditor } from '../rich-text-editor';
/** A drop from the desktop: the file plus its path as text, as Firefox sends it. */
function fileDrop(file: File) {
const text = `file:///C:/Users/me/Downloads/${file.name}`;
const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent;
Object.defineProperty(event, 'dataTransfer', {
value: {
files: [file],
types: ['Files', 'text/uri-list', 'text/plain'],
getData: (type: string) => (type === 'text/plain' || type === 'text/uri-list' ? text : ''),
},
});
return event;
}
async function setup(onImageUpload?: (file: File) => Promise<{ src: string } | null>) {
let editor: Editor | null = null;
const onDrop = vi.fn();
render(
<div onDrop={onDrop}>
<RichTextEditor
content="<p>Hello</p>"
onChange={() => {}}
onImageUpload={onImageUpload}
onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
/>
</div>
);
await waitFor(() => expect(editor).not.toBeNull());
const ed = editor as unknown as Editor;
// jsdom has no layout to resolve the drop point from.
ed.view.posAtCoords = () => ({ pos: 1, inside: -1 });
return { editor: ed, onDrop };
}
describe('RichTextEditor file drop', () => {
it('does not insert the path of a dropped file, and leaves the file to the composer', async () => {
const { editor, onDrop } = await setup(async () => ({ src: 'blob:x' }));
const event = fileDrop(new File(['%PDF'], 'report.pdf', { type: 'application/pdf' }));
act(() => { editor.view.dom.dispatchEvent(event); });
expect(editor.getText()).toBe('Hello');
expect(event.defaultPrevented).toBe(true);
expect(onDrop).toHaveBeenCalledTimes(1);
});
it('does not insert the path when there is no inline image upload', async () => {
const { editor, onDrop } = await setup();
const event = fileDrop(new File(['png'], 'photo.png', { type: 'image/png' }));
act(() => { editor.view.dom.dispatchEvent(event); });
expect(editor.getText()).toBe('Hello');
expect(onDrop).toHaveBeenCalledTimes(1);
});
it('still inlines a dropped image instead of attaching it', async () => {
const upload = vi.fn(async () => ({ src: 'blob:x' }));
const { editor, onDrop } = await setup(upload);
const file = new File(['png'], 'photo.png', { type: 'image/png' });
act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); });
expect(upload).toHaveBeenCalledWith(file);
expect(editor.getText()).toBe('Hello');
expect(onDrop).not.toHaveBeenCalled();
});
});
+8 -5
View File
@@ -293,12 +293,15 @@ export function RichTextEditor({
class: "tiptap min-h-[100px] px-4 py-3 text-sm text-foreground",
},
handleDrop: (view, event) => {
if (!event.dataTransfer?.files?.length) return false;
const upload = onImageUploadRef.current;
if (!upload || !event.dataTransfer?.files?.length) return false;
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
f.type.startsWith("image/")
);
if (imageFiles.length === 0) return false;
const imageFiles = upload
? Array.from(event.dataTransfer.files).filter(f => f.type.startsWith("image/"))
: [];
// A file dragged from the desktop also carries its path as text
// (file:///...), which ProseMirror would insert. Claim the drop but
// let it bubble so the composer attaches the file.
if (!upload || imageFiles.length === 0) return true;
event.preventDefault();
event.stopPropagation();
for (const file of imageFiles) {