fix: route clipboard image pastes to the attachment strip (#295)

* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* fix: disable Milkdown ImageBlock so paste never strands an upload node

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 <img>), 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtNQeBpkDJPC9obiB25pV6

* nit

* format

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #295.
This commit is contained in:
Arjun Patel
2026-06-21 09:15:21 -07:00
committed by GitHub
parent 661577ecd2
commit f2f8602263
4 changed files with 55 additions and 39 deletions
@@ -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;
}
@@ -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
@@ -57,23 +58,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')),
},
},
});
@@ -85,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(() => {
+14 -7
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { transferFiles } from '@/lib/data-transfer';
interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void;
@@ -48,15 +49,20 @@ export function useFileInput({
useEffect(() => {
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.
// 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;
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 = transferFiles(data);
if (files.length === 0) return;
e.preventDefault();
e.stopPropagation();
onFilesRef.current(files);
@@ -105,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);
}
+12
View File
@@ -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);
}