From b3084fd515cbc3bbc0e4e8085d7cb1714a98b34c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:46:24 +0000 Subject: [PATCH 1/5] fix: route clipboard image pastes to the attachment strip Pasting an image while the markdown editor was focused left it stuck on ProseMirror's inline "uploading" placeholder instead of going to the attachment strip. The capture-phase paste interceptor bailed before it could redirect the file because: - pasted images usually surface only through `clipboardData.items` (`getAsFile`), with `clipboardData.files` left empty, and - image pastes often advertise an *empty* `text/plain` entry, which the old `types.includes('text/plain')` check mistook for a text paste. Read files from `items` (falling back to `files`), and gate on the actual text payload rather than the advertised type, so real text pastes (Excel/Word renditions) still paste as text while pure image pastes become attachments. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6 --- js/desktop/src/hooks/use-file-input.ts | 29 +++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index 749c987..737b98c 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -5,6 +5,19 @@ interface UseFileInputOptions { 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, @@ -49,14 +62,20 @@ export function useFileInput({ if (!enabled) return; // Capture phase so file pastes always become attachments — ProseMirror - // would otherwise inline pasted images as ephemeral blob: URLs. Mixed - // clipboards (e.g. Excel/Word ship an image rendition alongside the text) - // must still paste as text, so only file-only pastes are intercepted. + // (the markdown editor) would otherwise inline a pasted image as an + // ephemeral blob: URL and get stuck on its "uploading" placeholder. const handlePaste = (e: ClipboardEvent) => { const data = e.clipboardData; if (!data) return; - const files = Array.from(data.files); - if (files.length === 0 || data.types.includes('text/plain')) return; + + // Actual text means "paste as text", even when the clipboard also ships + // an image rendition (Excel/Word, or "copy image" from a page with alt + // text). Test the payload, not the advertised types: image pastes often + // 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); + if (files.length === 0) return; e.preventDefault(); e.stopPropagation(); onFilesRef.current(files); -- 2.54.0 From 3a7d9a7c31bea72d16be07304d3506df35a262b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 15:42:39 +0000 Subject: [PATCH 2/5] fix: disable Milkdown ImageBlock so paste never strands an upload node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing pasted images to the attachment strip wasn't enough — the editor's ImageBlock feature still handled the paste itself and left a stuck "upload in progress" node, since there's no public upload URL for it to resolve to. Turn the feature off entirely. It's the root cause: it's the only thing that registers paste/drop-to-upload. Image *links* still render through the base commonmark schema (a plain ), and pasted image files continue to go to the attachment strip via use-file-input. Drop the now-dead image-block upload/chrome CSS and keep a simple inline-image style. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6 --- .../src/features/compose/markdown-editor.css | 26 +++++-------------- .../src/features/compose/markdown-editor.tsx | 17 +++++------- 2 files changed, 12 insertions(+), 31 deletions(-) diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css index 7227482..1262acc 100644 --- a/js/desktop/src/features/compose/markdown-editor.css +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -110,26 +110,12 @@ margin-top: 0; } -/* Images come from URLs only (no stable public upload URL), so hide the - * file uploader — the placeholder then just prompts for a link. */ -.llink-crepe - .milkdown - :is(.milkdown-image-block, .milkdown-image-inline) - .placeholder - .uploader { - display: none; -} - -/* Read-only renders the image node view with inert editing chrome — hide it. */ -.llink-crepe:not(.llink-crepe--fill) .milkdown .milkdown-image-block .operation, -.llink-crepe:not(.llink-crepe--fill) - .milkdown - .milkdown-image-block - .image-resize-handle { - display: none; -} - -.llink-crepe .milkdown .milkdown-image-block img { +/* Image *links* render through the base commonmark schema (the ImageBlock + * feature is off — see markdown-editor.tsx). Keep them within the content + * column and softly rounded. */ +.llink-crepe .milkdown .ProseMirror img { + max-width: 100%; + max-height: 420px; border-radius: 8px; } diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index 43b3e00..fb45e35 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -57,23 +57,18 @@ export function MarkdownEditor({ [Crepe.Feature.BlockEdit]: !readOnly, [Crepe.Feature.Toolbar]: !readOnly, [Crepe.Feature.Placeholder]: !readOnly, - [Crepe.Feature.ImageBlock]: true, + // Off on purpose: file uploads aren't supported, so this feature's + // paste/drop handler would only strand an "upload in progress" node in + // the editor. Image *links* still render through the base commonmark + // schema, and pasted image files are routed to the compose attachment + // strip (see use-file-input). + [Crepe.Feature.ImageBlock]: false, [Crepe.Feature.Latex]: false, [Crepe.Feature.TopBar]: false, [Crepe.Feature.AI]: false, }, featureConfigs: { [Crepe.Feature.Placeholder]: { text: placeholder ?? '' }, - // Images come from URLs only (e.g. pasted markdown) — there is no - // stable public upload URL, so the file uploader is hidden in CSS and - // onUpload rejects in case a file ever reaches it anyway (the default - // would serialize an ephemeral blob: URL into the message). - [Crepe.Feature.ImageBlock]: { - blockUploadPlaceholderText: 'Paste an image link…', - inlineUploadPlaceholderText: 'paste an image link', - maxHeight: 420, - onUpload: () => Promise.reject(new Error('Image uploads disabled')), - }, }, }); -- 2.54.0 From 841ab4cb94604ae54cf9e693439904849c15bf8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 15:49:27 +0000 Subject: [PATCH 3/5] 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); +} -- 2.54.0 From 372b12f339d78737e13da26bb27a3be03c8270d0 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 09:08:47 -0700 Subject: [PATCH 4/5] nit --- js/desktop/src/hooks/use-file-input.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index d53657d..bb4ff4c 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -111,7 +111,8 @@ export function useFileInput({ e.stopPropagation(); dragCountRef.current = 0; setIsDragging(false); - const files = Array.from(e.dataTransfer.files); + + const files = transferFiles(e.dataTransfer); if (files.length > 0) { onFilesRef.current(files); } -- 2.54.0 From fa88a7c07b4650520c3800164c23d0cd31816b93 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Sun, 21 Jun 2026 09:14:09 -0700 Subject: [PATCH 5/5] format --- js/desktop/src/hooks/use-file-input.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index bb4ff4c..7a5cd87 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -111,7 +111,7 @@ export function useFileInput({ e.stopPropagation(); dragCountRef.current = 0; setIsDragging(false); - + const files = transferFiles(e.dataTransfer); if (files.length > 0) { onFilesRef.current(files); -- 2.54.0