fix: route clipboard image pastes to the attachment strip #295
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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