fix: route clipboard image pastes to the attachment strip #295
@@ -1,11 +1,12 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Crepe } from '@milkdown/crepe';
|
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 { Selection } from '@milkdown/kit/prose/state';
|
||||||
import '@milkdown/crepe/theme/common/style.css';
|
import '@milkdown/crepe/theme/common/style.css';
|
||||||
import '@milkdown/crepe/theme/frame-dark.css';
|
import '@milkdown/crepe/theme/frame-dark.css';
|
||||||
import './markdown-editor.css';
|
import './markdown-editor.css';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface MarkdownEditorProps {
|
interface MarkdownEditorProps {
|
||||||
/** Initial markdown. The editor owns its content after mount; edits flow out
|
/** Initial markdown. The editor owns its content after mount; edits flow out
|
||||||
@@ -80,6 +81,21 @@ export function MarkdownEditor({
|
|||||||
onChangeRef.current?.(markdown);
|
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(() => {
|
crepe.create().then(() => {
|
||||||
|
|||||||
@@ -1,23 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { transferFiles } from '@/lib/data-transfer';
|
||||||
|
|
||||||
interface UseFileInputOptions {
|
interface UseFileInputOptions {
|
||||||
onFilesSelected: (files: File[]) => void;
|
onFilesSelected: (files: File[]) => void;
|
||||||
enabled: boolean;
|
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({
|
export function useFileInput({
|
||||||
onFilesSelected,
|
onFilesSelected,
|
||||||
enabled,
|
enabled,
|
||||||
@@ -61,9 +49,8 @@ export function useFileInput({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|
||||||
// Capture phase so file pastes always become attachments — ProseMirror
|
// Capture phase so file pastes always become attachments — the markdown
|
||||||
// (the markdown editor) would otherwise inline a pasted image as an
|
// editor would otherwise inline a pasted image as an ephemeral blob: URL.
|
||||||
// ephemeral blob: URL and get stuck on its "uploading" placeholder.
|
|
||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
const data = e.clipboardData;
|
const data = e.clipboardData;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -74,7 +61,7 @@ export function useFileInput({
|
|||||||
// list an *empty* text/plain entry that must not block the attachment.
|
// list an *empty* text/plain entry that must not block the attachment.
|
||||||
if (data.getData('text/plain').trim().length > 0) return;
|
if (data.getData('text/plain').trim().length > 0) return;
|
||||||
|
|
||||||
const files = clipboardFiles(data);
|
const files = transferFiles(data);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
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