From 3138c2bd04f9f91e68fcf98f5291cbea096d2865 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:58:19 +0200 Subject: [PATCH] fix: stop dropped files from inserting their path into the message body --- .../__tests__/rich-text-editor-drop.test.tsx | 75 +++++++++++++++++++ components/email/rich-text-editor.tsx | 13 ++-- 2 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 components/email/__tests__/rich-text-editor-drop.test.tsx diff --git a/components/email/__tests__/rich-text-editor-drop.test.tsx b/components/email/__tests__/rich-text-editor-drop.test.tsx new file mode 100644 index 000000000..235cffce7 --- /dev/null +++ b/components/email/__tests__/rich-text-editor-drop.test.tsx @@ -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( +
+ {}} + onImageUpload={onImageUpload} + onEditorReady={(ed) => { editor = ed as unknown as Editor; }} + /> +
+ ); + 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(); + }); +}); diff --git a/components/email/rich-text-editor.tsx b/components/email/rich-text-editor.tsx index bd8bd9aa0..35413bd10 100644 --- a/components/email/rich-text-editor.tsx +++ b/components/email/rich-text-editor.tsx @@ -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) {