diff --git a/js/desktop/src/features/compose/markdown-editor.css b/js/desktop/src/features/compose/markdown-editor.css index 7227482..1262acc 100644 --- a/js/desktop/src/features/compose/markdown-editor.css +++ b/js/desktop/src/features/compose/markdown-editor.css @@ -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; } diff --git a/js/desktop/src/features/compose/markdown-editor.tsx b/js/desktop/src/features/compose/markdown-editor.tsx index 43b3e00..1ff08d7 100644 --- a/js/desktop/src/features/compose/markdown-editor.tsx +++ b/js/desktop/src/features/compose/markdown-editor.tsx @@ -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(() => { diff --git a/js/desktop/src/hooks/use-file-input.ts b/js/desktop/src/hooks/use-file-input.ts index 749c987..7a5cd87 100644 --- a/js/desktop/src/hooks/use-file-input.ts +++ b/js/desktop/src/hooks/use-file-input.ts @@ -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); } diff --git a/js/desktop/src/lib/data-transfer.ts b/js/desktop/src/lib/data-transfer.ts new file mode 100644 index 0000000..4078729 --- /dev/null +++ b/js/desktop/src/lib/data-transfer.ts @@ -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); +}