From 841ab4cb94604ae54cf9e693439904849c15bf8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 15:49:27 +0000 Subject: [PATCH] fix: decline dropped files in the editor so they don't inline Dragging an image onto the markdown editor attached it to the strip (the drop zone fired) but ProseMirror also handled the same drop, parsing the drag's HTML into an inline image node with a blob:/localhost src. That URL then leaked into the markdown and surfaced as a stray link-preview chip. Add a ProseMirror handleDrop that declines drops carrying files, so the editor stops inlining them while the drop zone still routes them to the attachment strip. Extract the paste/drag file extraction into a shared transferFiles helper used by both paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6 --- .../src/features/compose/markdown-editor.tsx | 18 +++++++++++++++- js/desktop/src/hooks/use-file-input.ts | 21 ++++--------------- js/desktop/src/lib/data-transfer.ts | 12 +++++++++++ 3 files changed, 33 insertions(+), 18 deletions(-) create mode 100644 js/desktop/src/lib/data-transfer.ts diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index fb45e35..1ff08d7 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef } from 'react'; import { Crepe } from '@milkdown/crepe'; -import { editorViewCtx } from '@milkdown/kit/core'; +import { editorViewCtx, editorViewOptionsCtx } from '@milkdown/kit/core'; import { Selection } from '@milkdown/kit/prose/state'; import '@milkdown/crepe/theme/common/style.css'; import '@milkdown/crepe/theme/frame-dark.css'; import './markdown-editor.css'; import { cn } from '@/lib/utils'; +import { transferFiles } from '@/lib/data-transfer'; interface MarkdownEditorProps { /** Initial markdown. The editor owns its content after mount; edits flow out @@ -80,6 +81,21 @@ export function MarkdownEditor({ onChangeRef.current?.(markdown); }); }); + + // Decline dropped files: they belong in the compose attachment strip + // (the drop zone still receives them), not inlined into the document. + // Without this the editor parses the drag's HTML into an image node with + // an ephemeral blob:/localhost src, which then leaks into the markdown. + crepe.editor.config((ctx) => { + ctx.update(editorViewOptionsCtx, (prev) => ({ + ...prev, + handleDrop: (view, event, slice, moved) => { + const data = event.dataTransfer; + if (data && transferFiles(data).length > 0) return true; + return prev.handleDrop?.(view, event, slice, moved) ?? false; + }, + })); + }); } crepe.create().then(() => { diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index 737b98c..d53657d 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -1,23 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +import { transferFiles } from '@/lib/data-transfer'; interface UseFileInputOptions { onFilesSelected: (files: File[]) => void; enabled: boolean; } -/** - * Files carried by a paste. A pasted image is usually exposed only through - * `items` (`getAsFile`), with `files` left empty, so read `items` first and - * fall back to `files` for the rare clipboard that populates only the latter. - */ -function clipboardFiles(data: DataTransfer): File[] { - const fromItems = Array.from(data.items) - .filter((item) => item.kind === 'file') - .map((item) => item.getAsFile()) - .filter((file): file is File => file !== null); - return fromItems.length > 0 ? fromItems : Array.from(data.files); -} - export function useFileInput({ onFilesSelected, enabled, @@ -61,9 +49,8 @@ export function useFileInput({ useEffect(() => { if (!enabled) return; - // Capture phase so file pastes always become attachments — ProseMirror - // (the markdown editor) would otherwise inline a pasted image as an - // ephemeral blob: URL and get stuck on its "uploading" placeholder. + // Capture phase so file pastes always become attachments — the markdown + // editor would otherwise inline a pasted image as an ephemeral blob: URL. const handlePaste = (e: ClipboardEvent) => { const data = e.clipboardData; if (!data) return; @@ -74,7 +61,7 @@ export function useFileInput({ // list an *empty* text/plain entry that must not block the attachment. if (data.getData('text/plain').trim().length > 0) return; - const files = clipboardFiles(data); + const files = transferFiles(data); if (files.length === 0) return; e.preventDefault(); e.stopPropagation(); diff --git a/js/desktop/src/lib/data-transfer.ts b/js/desktop/src/lib/data-transfer.ts new file mode 100644 index 0000000..4078729 --- /dev/null +++ b/js/desktop/src/lib/data-transfer.ts @@ -0,0 +1,12 @@ +/** + * Files carried by a paste or drag {@link DataTransfer}. Pasted/dragged images + * frequently surface only through `items` (`getAsFile`), with `files` left + * empty, so read `items` first and fall back to `files`. + */ +export function transferFiles(data: DataTransfer): File[] { + const fromItems = Array.from(data.items) + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null); + return fromItems.length > 0 ? fromItems : Array.from(data.files); +}