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
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user