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

Merged
talksik merged 5 commits from claude/markdown-image-upload-fix-t9ggfa into main 2026-06-21 16:15:21 +00:00
3 changed files with 33 additions and 18 deletions
Showing only changes of commit 841ab4cb94 - Show all commits
@@ -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(() => {
+4 -17
View File
@@ -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();
+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);
}