From f9400df74fc4ac5e8a5166da3880a36b0175dbfb Mon Sep 17 00:00:00 2001 From: Array Fleet Date: Tue, 30 Jun 2026 14:38:29 +0000 Subject: [PATCH 1/5] feat(ui): image paste from clipboard in editor (#425) Enable clipboard paste and drag-and-drop image upload in both visual (BlockNote) and source (CodeMirror) editor modes with loading placeholders, standardized paste filenames, and error toasts. Co-authored-by: Cursor --- .../2026-06-30-image-paste-clipboard.md | 35 ++++++ ui/src/components/EditorImageDropOverlay.tsx | 30 +++++ ui/src/components/KiwiEditor.tsx | 118 +++++++++++++++++- .../editor/MarkdownSourceEditor.tsx | 11 +- ui/src/lib/editorImagePaste.test.ts | 62 +++++++++ ui/src/lib/editorImagePaste.ts | 114 +++++++++++++++++ ui/src/lib/editorImagePasteExtension.test.ts | 74 +++++++++++ ui/src/lib/editorImagePasteExtension.ts | 104 +++++++++++++++ ui/src/lib/imagePasteProsemirrorPlugin.ts | 73 +++++++++++ 9 files changed, 617 insertions(+), 4 deletions(-) create mode 100644 episodes/agents/cursor-issue-425/2026-06-30-image-paste-clipboard.md create mode 100644 ui/src/components/EditorImageDropOverlay.tsx create mode 100644 ui/src/lib/editorImagePaste.test.ts create mode 100644 ui/src/lib/editorImagePaste.ts create mode 100644 ui/src/lib/editorImagePasteExtension.test.ts create mode 100644 ui/src/lib/editorImagePasteExtension.ts create mode 100644 ui/src/lib/imagePasteProsemirrorPlugin.ts diff --git a/episodes/agents/cursor-issue-425/2026-06-30-image-paste-clipboard.md b/episodes/agents/cursor-issue-425/2026-06-30-image-paste-clipboard.md new file mode 100644 index 00000000..0ac4fd37 --- /dev/null +++ b/episodes/agents/cursor-issue-425/2026-06-30-image-paste-clipboard.md @@ -0,0 +1,35 @@ +--- +memory_kind: episodic +episode_id: cursor-issue-425-2026-06-30 +title: Issue #425 image paste from clipboard +tags: [kiwifs, ui, editor, image-paste, issue-425] +date: 2026-06-30 +--- + +# Issue #425 — image paste from clipboard + +## Task + +Implement clipboard image paste and drag-and-drop in KiwiEditor (visual BlockNote + source CodeMirror) per [kiwifs/kiwifs#425](https://github.com/kiwifs/kiwifs/issues/425). + +## Approach + +- Shared helpers in `editorImagePaste.ts` (MIME detection, `paste-YYYYMMDD-HHMMSS.ext` naming, relative markdown refs). +- Source mode: CodeMirror `editorImagePasteExtension` inserts `![Uploading...]()` placeholder, uploads via `api.uploadAsset`, replaces with `![name](relative-path)`. +- Visual mode: BlockNote native `uploadFile` path with `renameFileForPaste`, block removal + toast on failure. +- Drop-zone overlay on editor area for both modes. +- Error toast bottom-left (mirrors slash-command alert pattern). + +## Tests + +```bash +cd ui && npm test -- src/lib/editorImagePaste.test.ts src/lib/editorImagePasteExtension.test.ts +# 11 passed + +cd ui && npm test +# 217 passed +``` + +## Branch + +`feat/issue-425-image-paste` (local commit only; fleet publishes PR). diff --git a/ui/src/components/EditorImageDropOverlay.tsx b/ui/src/components/EditorImageDropOverlay.tsx new file mode 100644 index 00000000..89ce7b26 --- /dev/null +++ b/ui/src/components/EditorImageDropOverlay.tsx @@ -0,0 +1,30 @@ +import { ImagePlus } from "lucide-react"; +import { cn } from "@kw/lib/cn"; +import { isOsImageDrag } from "@kw/lib/editorImagePaste"; + +type Props = { + active: boolean; + className?: string; +}; + +export function EditorImageDropOverlay({ active, className }: Props) { + if (!active) return null; + return ( +
+
+ + Drop image to upload +
+
+ ); +} + +export function shouldShowEditorImageDropOverlay(event: DragEvent): boolean { + return isOsImageDrag(event); +} diff --git a/ui/src/components/KiwiEditor.tsx b/ui/src/components/KiwiEditor.tsx index 0fda1e9d..7e3fe976 100644 --- a/ui/src/components/KiwiEditor.tsx +++ b/ui/src/components/KiwiEditor.tsx @@ -34,6 +34,8 @@ import { formatDistanceToNow } from "date-fns"; import { MarkdownSourceEditor } from "./editor/MarkdownSourceEditor"; import { blockNoteSlashItems, loadSlashCommandTemplate } from "@kw/lib/editorSlashCommands"; import { useEditorSlashCommands } from "../hooks/useEditorSlashCommands"; +import { EditorImageDropOverlay } from "./EditorImageDropOverlay"; +import { hasImageInDataTransfer, isOsImageDrag, renameFileForPaste } from "@kw/lib/editorImagePaste"; import { Dialog, DialogContent, @@ -427,6 +429,11 @@ function EditorInner({ const customSlashCommands = useEditorSlashCommands(); const [slashCommandError, setSlashCommandError] = useState(null); const slashCommandErrorTimer = useRef(null); + const [imagePasteError, setImagePasteError] = useState(null); + const imagePasteErrorTimer = useRef(null); + const [imageDropActive, setImageDropActive] = useState(false); + const imageDropDepthRef = useRef(0); + const editorRef = useRef(null); const onSlashTemplateError = useCallback((message: string) => { setSlashCommandError(message); if (slashCommandErrorTimer.current !== null) { @@ -437,6 +444,16 @@ function EditorInner({ slashCommandErrorTimer.current = null; }, 6000); }, []); + const onImagePasteError = useCallback((message: string) => { + setImagePasteError(message); + if (imagePasteErrorTimer.current !== null) { + window.clearTimeout(imagePasteErrorTimer.current); + } + imagePasteErrorTimer.current = window.setTimeout(() => { + setImagePasteError(null); + imagePasteErrorTimer.current = null; + }, 6000); + }, []); const loadSlashTemplate = useCallback((templatePath: string) => loadSlashCommandTemplate(templatePath), []); useEffect(() => { @@ -444,6 +461,9 @@ function EditorInner({ if (slashCommandErrorTimer.current !== null) { window.clearTimeout(slashCommandErrorTimer.current); } + if (imagePasteErrorTimer.current !== null) { + window.clearTimeout(imagePasteErrorTimer.current); + } }; }, []); @@ -480,14 +500,35 @@ function EditorInner({ return () => { cancelled = true; }; }, [path]); - const uploadFile = useCallback( + const uploadAssetForEditor = useCallback( async (file: File) => { const targetDir = dirOf(path); - return api.uploadAsset(file, targetDir); + return api.uploadAsset(renameFileForPaste(file), targetDir); }, [path], ); + const uploadFile = useCallback( + async (file: File, blockId?: string) => { + try { + return await uploadAssetForEditor(file); + } catch (e) { + const ed = editorRef.current; + if (blockId && ed) { + try { + const block = ed.getBlock(blockId); + if (block) ed.removeBlocks([block]); + } catch { + // block may already be gone + } + } + onImagePasteError(e instanceof Error ? e.message : String(e)); + throw e; + } + }, + [uploadAssetForEditor, onImagePasteError], + ); + const editorOptions = useMemo( () => ({ uploadFile, @@ -499,6 +540,10 @@ function EditorInner({ ); const editor = useCreateBlockNote(editorOptions); + useEffect(() => { + editorRef.current = editor ?? null; + }, [editor]); + useEffect(() => { if (!editor) return; const pm = (editor as any)._tiptapEditor?.view; @@ -742,6 +787,46 @@ function EditorInner({ [markDirty], ); + const resetImageDrop = useCallback(() => { + imageDropDepthRef.current = 0; + setImageDropActive(false); + }, []); + + const handleEditorDragEnter = useCallback((e: React.DragEvent) => { + if (!isOsImageDrag(e) || !hasImageInDataTransfer(e.dataTransfer)) return; + e.preventDefault(); + imageDropDepthRef.current += 1; + setImageDropActive(true); + }, []); + + const handleEditorDragLeave = useCallback((e: React.DragEvent) => { + if (!isOsImageDrag(e)) return; + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + imageDropDepthRef.current = Math.max(0, imageDropDepthRef.current - 1); + if (imageDropDepthRef.current === 0) setImageDropActive(false); + }, []); + + const handleEditorDragOver = useCallback((e: React.DragEvent) => { + if (!isOsImageDrag(e) || !hasImageInDataTransfer(e.dataTransfer)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + }, []); + + useEffect(() => { + const onDragEnd = () => resetImageDrop(); + window.addEventListener("dragend", onDragEnd); + return () => window.removeEventListener("dragend", onDragEnd); + }, [resetImageDrop]); + + const sourceImagePaste = useMemo( + () => ({ + pagePath: path, + uploadImage: uploadAssetForEditor, + onError: onImagePasteError, + }), + [path, uploadAssetForEditor, onImagePasteError], + ); + const canSave = saveStatus !== "clean" && !saving && @@ -839,7 +924,14 @@ function EditorInner({ )} -
+
+ {editorMode === "source" ? ( ) : visualParseError ? (
@@ -974,6 +1067,25 @@ function EditorInner({
)} + {imagePasteError && ( +
+
+ +

{imagePasteError}

+ +
+
+ )}
); } diff --git a/ui/src/components/editor/MarkdownSourceEditor.tsx b/ui/src/components/editor/MarkdownSourceEditor.tsx index 7a986929..1e2c491d 100644 --- a/ui/src/components/editor/MarkdownSourceEditor.tsx +++ b/ui/src/components/editor/MarkdownSourceEditor.tsx @@ -14,6 +14,10 @@ import { wikiLinkCompletionSource, type WikiPage, } from "./wikiLinkCompletion"; +import { + editorImagePasteExtension, + type EditorImagePasteOptions, +} from "@kw/lib/editorImagePasteExtension"; export type MarkdownSourceEditorProps = { value: string; @@ -27,6 +31,7 @@ export type MarkdownSourceEditorProps = { customSlashCommands?: EditorSlashCommandConfig[]; loadSlashTemplate?: (templatePath: string) => Promise; onSlashTemplateError?: (message: string) => void; + imagePaste?: Pick; }; export function MarkdownSourceEditor({ @@ -41,6 +46,7 @@ export function MarkdownSourceEditor({ customSlashCommands = [], loadSlashTemplate, onSlashTemplateError, + imagePaste, }: MarkdownSourceEditorProps) { const extensions = useMemo(() => { const saveKeymap = keymap.of([ @@ -80,8 +86,11 @@ export function MarkdownSourceEditor({ keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), saveKeymap, ]; + if (imagePaste) { + exts.push(editorImagePasteExtension(imagePaste)); + } return exts; - }, [onSaveShortcut, pages, customSlashCommands, loadSlashTemplate, onSlashTemplateError]); + }, [onSaveShortcut, pages, customSlashCommands, loadSlashTemplate, onSlashTemplateError, imagePaste]); const theme = useMemo(() => markdownEditorTheme({ dark }), [dark]); diff --git a/ui/src/lib/editorImagePaste.test.ts b/ui/src/lib/editorImagePaste.test.ts new file mode 100644 index 00000000..c2b4687e --- /dev/null +++ b/ui/src/lib/editorImagePaste.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + assetUrlToMarkdownRef, + extractImagesFromDataTransfer, + hasImageInDataTransfer, + isPasteableImageType, + markdownImageRef, + pasteImageFileName, + renameFileForPaste, + uploadingPlaceholder, +} from "./editorImagePaste"; + +describe("editorImagePaste", () => { + it("detects supported image MIME types", () => { + expect(isPasteableImageType("image/png")).toBe(true); + expect(isPasteableImageType("image/jpeg")).toBe(true); + expect(isPasteableImageType("text/plain")).toBe(false); + }); + + it("generates paste-YYYYMMDD-HHMMSS filenames", () => { + const now = new Date("2026-06-23T14:30:22"); + expect(pasteImageFileName("image/png", now)).toBe("paste-20260623-143022.png"); + expect(pasteImageFileName("image/jpeg", now)).toBe("paste-20260623-143022.jpg"); + }); + + it("renames clipboard files for upload", () => { + const file = new File(["x"], "screenshot.png", { type: "image/png" }); + const now = new Date("2026-06-23T14:30:22"); + const renamed = renameFileForPaste(file, now); + expect(renamed.name).toBe("paste-20260623-143022.png"); + expect(renamed.type).toBe("image/png"); + }); + + it("extracts image files from DataTransfer items", () => { + const png = new File(["a"], "a.png", { type: "image/png" }); + const items = [ + { kind: "file", type: "image/png", getAsFile: () => png }, + { kind: "file", type: "text/plain", getAsFile: () => new File(["b"], "b.txt", { type: "text/plain" }) }, + ]; + const dt = { items, files: [] } as unknown as DataTransfer; + expect(extractImagesFromDataTransfer(dt)).toEqual([png]); + expect(hasImageInDataTransfer(dt)).toBe(true); + }); + + it("maps uploaded /raw/ URLs to page-relative markdown refs", () => { + expect(assetUrlToMarkdownRef("/raw/notes/paste-1.png", "notes/page.md")).toBe( + "paste-1.png", + ); + expect(assetUrlToMarkdownRef("/raw/paste-1.png", "page.md")).toBe("paste-1.png"); + }); + + it("builds markdown image syntax", () => { + expect(markdownImageRef("shot", "paste-1.png")).toBe("![shot](paste-1.png)"); + }); + + it("creates unique uploading placeholders", () => { + const a = uploadingPlaceholder("abc"); + const b = uploadingPlaceholder("def"); + expect(a).toContain("Uploading..."); + expect(a).not.toBe(b); + }); +}); diff --git a/ui/src/lib/editorImagePaste.ts b/ui/src/lib/editorImagePaste.ts new file mode 100644 index 00000000..f47c5f36 --- /dev/null +++ b/ui/src/lib/editorImagePaste.ts @@ -0,0 +1,114 @@ +import { dirOf } from "./paths"; + +/** Clipboard / drag image MIME types supported by the asset upload API. */ +export const PASTE_IMAGE_MIME_TYPES = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", +] as const; + +export const UPLOADING_ALT = "Uploading..."; +const UPLOADING_URL_PREFIX = "kiwi-upload://"; + +export function isPasteableImageType(mime: string): boolean { + return (PASTE_IMAGE_MIME_TYPES as readonly string[]).includes(mime); +} + +export function extensionForImageMime(mime: string): string { + switch (mime) { + case "image/png": + return "png"; + case "image/jpeg": + return "jpg"; + case "image/gif": + return "gif"; + case "image/webp": + return "webp"; + default: + return "png"; + } +} + +/** Standardize clipboard paste filenames: paste-YYYYMMDD-HHMMSS.ext */ +export function pasteImageFileName(mime: string, now = new Date()): string { + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + "-", + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(""); + return `paste-${stamp}.${extensionForImageMime(mime)}`; +} + +export function renameFileForPaste(file: File, now = new Date()): File { + const mime = file.type || "image/png"; + const name = pasteImageFileName(mime, now); + return new File([file], name, { type: mime, lastModified: file.lastModified }); +} + +export function extractImagesFromDataTransfer( + dataTransfer: DataTransfer | null | undefined, +): File[] { + if (!dataTransfer) return []; + const files: File[] = []; + const items = dataTransfer.items; + if (items && items.length > 0) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.kind !== "file") continue; + if (!isPasteableImageType(item.type)) continue; + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; + } + return Array.from(dataTransfer.files).filter((f) => isPasteableImageType(f.type)); +} + +export function hasImageInDataTransfer( + dataTransfer: DataTransfer | null | undefined, +): boolean { + return extractImagesFromDataTransfer(dataTransfer).length > 0; +} + +export function isOsImageDrag(event: { dataTransfer?: DataTransfer | null }): boolean { + const types = event.dataTransfer?.types; + if (!types) return false; + return Array.from(types).includes("Files"); +} + +export function uploadingPlaceholder(token: string): string { + return `![${UPLOADING_ALT}](${UPLOADING_URL_PREFIX}${token})`; +} + +export function isUploadingPlaceholder(text: string): boolean { + return text.includes(UPLOADING_URL_PREFIX); +} + +/** Map /raw/workspace/path.png to a markdown ref relative to the edited page. */ +export function assetUrlToMarkdownRef(rawUrl: string, pagePath: string): string { + const assetPath = rawUrl.replace(/^\/raw\//, ""); + const pageDir = dirOf(pagePath); + if (!pageDir) return assetPath; + if (assetPath === pageDir) return basename(assetPath); + const prefix = `${pageDir}/`; + if (assetPath.startsWith(prefix)) { + return assetPath.slice(prefix.length); + } + return assetPath; +} + +export function markdownImageRef(alt: string, ref: string): string { + const safeAlt = alt.replace(/[\[\]]/g, ""); + return `![${safeAlt}](${ref})`; +} + +function basename(p: string): string { + const idx = p.lastIndexOf("/"); + return idx < 0 ? p : p.slice(idx + 1); +} diff --git a/ui/src/lib/editorImagePasteExtension.test.ts b/ui/src/lib/editorImagePasteExtension.test.ts new file mode 100644 index 00000000..6fa37c45 --- /dev/null +++ b/ui/src/lib/editorImagePasteExtension.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { + insertUploadedImage, + replacePlaceholderInView, + removePlaceholderFromView, +} from "./editorImagePasteExtension"; + +function mockView(doc: string) { + let current = doc; + const dispatch = vi.fn((update: { changes?: { from: number; to?: number; insert?: string } }) => { + const { changes } = update; + if (!changes) return; + const before = current.slice(0, changes.from); + const after = current.slice(changes.to ?? changes.from); + current = before + (changes.insert ?? "") + after; + }); + return { + get doc() { + return current; + }, + state: { + get doc() { + return { toString: () => current }; + }, + selection: { main: { head: current.length } }, + }, + dispatch, + }; +} + +describe("editorImagePasteExtension", () => { + it("replaces uploading placeholder with markdown image on success", async () => { + const view = mockView("Hello ![Uploading...](kiwi-upload://tok)"); + const placeholder = "![Uploading...](kiwi-upload://tok)"; + const file = new File(["x"], "clip.png", { type: "image/png" }); + const uploadImage = vi.fn().mockResolvedValue("/raw/notes/paste-20260623-143022.png"); + + await insertUploadedImage(view as any, file, placeholder, { + pagePath: "notes/page.md", + uploadImage, + onError: vi.fn(), + }); + + expect(uploadImage).toHaveBeenCalled(); + expect(view.doc).toMatch(/^Hello !\[paste-\d{8}-\d{6}\.png\]\(paste-\d{8}-\d{6}\.png\)$/); + }); + + it("removes placeholder and reports error when upload fails", async () => { + const view = mockView("![Uploading...](kiwi-upload://tok)"); + const onError = vi.fn(); + const file = new File(["x"], "clip.png", { type: "image/png" }); + + await insertUploadedImage(view as any, file, "![Uploading...](kiwi-upload://tok)", { + pagePath: "notes/page.md", + uploadImage: vi.fn().mockRejectedValue(new Error("File too large")), + onError, + }); + + expect(onError).toHaveBeenCalledWith("File too large"); + expect(view.doc).toBe(""); + }); + + it("replacePlaceholderInView swaps text in document", () => { + const view = mockView("aaPLACEHOLDERbb"); + replacePlaceholderInView(view as any, "PLACEHOLDER", "OK"); + expect(view.doc).toBe("aaOKbb"); + }); + + it("removePlaceholderFromView deletes marker text", () => { + const view = mockView("aaPLACEHOLDERbb"); + removePlaceholderFromView(view as any, "PLACEHOLDER"); + expect(view.doc).toBe("aabb"); + }); +}); diff --git a/ui/src/lib/editorImagePasteExtension.ts b/ui/src/lib/editorImagePasteExtension.ts new file mode 100644 index 00000000..53faaf69 --- /dev/null +++ b/ui/src/lib/editorImagePasteExtension.ts @@ -0,0 +1,104 @@ +import { EditorView } from "@codemirror/view"; +import type { Extension } from "@codemirror/state"; +import { + assetUrlToMarkdownRef, + extractImagesFromDataTransfer, + hasImageInDataTransfer, + isOsImageDrag, + markdownImageRef, + renameFileForPaste, + uploadingPlaceholder, +} from "./editorImagePaste"; + +export type EditorImagePasteOptions = { + pagePath: string; + uploadImage: (file: File) => Promise; + onError: (message: string) => void; +}; + +export function replacePlaceholderInView( + view: EditorView, + placeholder: string, + replacement: string, +): void { + const doc = view.state.doc.toString(); + const idx = doc.indexOf(placeholder); + if (idx < 0) return; + view.dispatch({ + changes: { from: idx, to: idx + placeholder.length, insert: replacement }, + }); +} + +export function removePlaceholderFromView(view: EditorView, placeholder: string): void { + const doc = view.state.doc.toString(); + const idx = doc.indexOf(placeholder); + if (idx < 0) return; + view.dispatch({ + changes: { from: idx, to: idx + placeholder.length, insert: "" }, + }); +} + +export async function insertUploadedImage( + view: EditorView, + file: File, + placeholder: string, + opts: EditorImagePasteOptions, +): Promise { + try { + const renamed = renameFileForPaste(file); + const rawUrl = await opts.uploadImage(renamed); + const ref = assetUrlToMarkdownRef(rawUrl, opts.pagePath); + const replacement = markdownImageRef(renamed.name, ref); + replacePlaceholderInView(view, placeholder, replacement); + } catch (e) { + removePlaceholderFromView(view, placeholder); + const msg = e instanceof Error ? e.message : String(e); + opts.onError(msg || "Image upload failed"); + } +} + +export function beginImageInsert( + view: EditorView, + file: File, + opts: EditorImagePasteOptions, +): string { + const token = crypto.randomUUID(); + const placeholder = uploadingPlaceholder(token); + const pos = view.state.selection.main.head; + view.dispatch({ + changes: { from: pos, insert: placeholder }, + selection: { anchor: pos + placeholder.length }, + }); + void insertUploadedImage(view, file, placeholder, opts); + return placeholder; +} + +export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extension { + return EditorView.domEventHandlers({ + paste(event, view) { + if (!hasImageInDataTransfer(event.clipboardData)) return false; + const files = extractImagesFromDataTransfer(event.clipboardData); + if (files.length === 0) return false; + event.preventDefault(); + beginImageInsert(view, files[0], opts); + return true; + }, + drop(event, view) { + if (!isOsImageDrag(event)) return false; + const files = extractImagesFromDataTransfer(event.dataTransfer); + if (files.length === 0) return false; + event.preventDefault(); + const pos = view.posAtCoords({ x: event.clientX, y: event.clientY }); + if (pos) { + view.dispatch({ selection: { anchor: pos } }); + } + beginImageInsert(view, files[0], opts); + return true; + }, + dragover(event) { + if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) return false; + event.preventDefault(); + return true; + }, + }); +} diff --git a/ui/src/lib/imagePasteProsemirrorPlugin.ts b/ui/src/lib/imagePasteProsemirrorPlugin.ts new file mode 100644 index 00000000..2015340d --- /dev/null +++ b/ui/src/lib/imagePasteProsemirrorPlugin.ts @@ -0,0 +1,73 @@ +import { Plugin, PluginKey } from "prosemirror-state"; +import type { EditorView } from "prosemirror-view"; +import { + extractImagesFromDataTransfer, + hasImageInDataTransfer, + isOsImageDrag, + renameFileForPaste, +} from "./editorImagePaste"; + +export type ImagePastePluginOptions = { + uploadImage: (file: File) => Promise; + onError: (message: string) => void; + onUploaded?: () => void; +}; + +const imagePasteKey = new PluginKey("kiwi-image-paste"); + +async function uploadAndInsertImage( + view: EditorView, + file: File, + pos: number, + opts: ImagePastePluginOptions, +): Promise { + const renamed = renameFileForPaste(file); + try { + const url = await opts.uploadImage(renamed); + const imageNode = view.state.schema.nodes.image?.create({ src: url, alt: renamed.name }); + if (!imageNode) { + opts.onError("Editor does not support image nodes"); + return; + } + const tr = view.state.tr.insert(pos, imageNode); + view.dispatch(tr); + opts.onUploaded?.(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + opts.onError(msg || "Image upload failed"); + } +} + +export function imagePasteProsemirrorPlugin(opts: ImagePastePluginOptions): Plugin { + return new Plugin({ + key: imagePasteKey, + props: { + handleDOMEvents: { + paste(view, event) { + if (!hasImageInDataTransfer(event.clipboardData)) return false; + const files = extractImagesFromDataTransfer(event.clipboardData); + if (files.length === 0) return false; + event.preventDefault(); + const pos = view.state.selection.from; + void uploadAndInsertImage(view, files[0], pos, opts); + return true; + }, + drop(view, event) { + if (!isOsImageDrag(event)) return false; + const files = extractImagesFromDataTransfer(event.dataTransfer); + if (files.length === 0) return false; + event.preventDefault(); + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + const pos = coords?.pos ?? view.state.selection.from; + void uploadAndInsertImage(view, files[0], pos, opts); + return true; + }, + dragover(_view, event) { + if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) return false; + event.preventDefault(); + return true; + }, + }, + }, + }); +} From 2a00f0f68baa95f2ff2ad5af4f365c491e7e7e52 Mon Sep 17 00:00:00 2001 From: Array Fleet Date: Tue, 30 Jun 2026 21:17:55 +0000 Subject: [PATCH 2/5] docs(episodes): log issue #425 image paste redelivery run Co-authored-by: Cursor --- .../2026-06-30-image-paste-redelivery.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 episodes/agents/cursor-issue-425/2026-06-30-image-paste-redelivery.md diff --git a/episodes/agents/cursor-issue-425/2026-06-30-image-paste-redelivery.md b/episodes/agents/cursor-issue-425/2026-06-30-image-paste-redelivery.md new file mode 100644 index 00000000..908a2cfe --- /dev/null +++ b/episodes/agents/cursor-issue-425/2026-06-30-image-paste-redelivery.md @@ -0,0 +1,42 @@ +--- +memory_kind: episodic +episode_id: cursor-issue-425-redelivery-2026-06-30 +title: Issue #425 image paste redelivery +tags: [kiwifs, ui, editor, image-paste, issue-425, redelivery] +date: 2026-06-30 +--- + +# Issue #425 — image paste redelivery + +## Task + +Re-deliver clipboard image paste and drag-and-drop for KiwiEditor per [kiwifs/kiwifs#425](https://github.com/kiwifs/kiwifs/issues/425). + +## Approach + +Cherry-picked `b3cdd95` onto `origin/main` as branch `feat/issue-425-image-paste-redelivery`. Removed Cursor co-author attribution from commit message. + +Implementation unchanged from prior delivery: + +- Shared helpers in `editorImagePaste.ts` +- Source mode: CodeMirror `editorImagePasteExtension` with upload placeholder +- Visual mode: BlockNote `uploadFile` wrapper with rename + error toast +- Drop-zone overlay via `EditorImageDropOverlay.tsx` + +## Tests + +```bash +cd ui && npm test -- --run editorImagePaste +# 11 passed + +cd ui && npm test -- --run +# 200 passed (35 files) +``` + +## Branch + +`feat/issue-425-image-paste-redelivery` — local only; fleet publishes PR. + +## Kiwi MCP + +MCP gateway unavailable this run; fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md` updated locally. From 3930f80444e0c217da3d6d18f6a26968a86b5065 Mon Sep 17 00:00:00 2001 From: Array Fleet Date: Tue, 30 Jun 2026 21:19:40 +0000 Subject: [PATCH 3/5] fix(ui): avoid double rename on source-mode image paste (#425) CodeMirror upload already renames via uploadAssetForEditor; derive alt text from the uploaded asset ref instead of pre-renaming the file twice. Co-authored-by: Cursor --- .../2026-06-30-image-paste-delivery.md | 31 +++++++++++++++++++ ui/src/lib/editorImagePasteExtension.ts | 7 ++--- 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-delivery.md diff --git a/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-delivery.md b/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-delivery.md new file mode 100644 index 00000000..32e83a38 --- /dev/null +++ b/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-delivery.md @@ -0,0 +1,31 @@ +--- +memory_kind: episodic +episode_id: cursor-hands-on-425-2026-06-30 +title: Issue #425 image paste hands-on delivery +tags: [kiwifs, ui, editor, image-paste, issue-425, hands-on] +date: 2026-06-30 +--- + +# Issue #425 — hands-on delivery + +## Task + +Verify and ship clipboard image paste + drag-and-drop for KiwiEditor ([#425](https://github.com/kiwifs/kiwifs/issues/425)) after fleet agent delivery check failed (no push, no PR). + +## Actions + +1. Verified existing implementation on `feat/issue-425-image-paste-redelivery` (2 commits ahead of `origin/main`). +2. Fixed double `renameFileForPaste` in CodeMirror upload path — `uploadAssetForEditor` already renames; alt text now derived from uploaded asset ref basename. +3. Ran full UI test suite — 200 passed (11 image-paste tests). +4. Pushed branch and opened PR closing #425. + +## Tests + +```bash +cd ui && npm test -- --run editorImagePaste # 11 passed +cd ui && npm test -- --run # 200 passed (35 files) +``` + +## Kiwi MCP + +Gateway at `192.168.167.240:3333` unreachable; fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md` in repo. diff --git a/ui/src/lib/editorImagePasteExtension.ts b/ui/src/lib/editorImagePasteExtension.ts index 53faaf69..6eeaa936 100644 --- a/ui/src/lib/editorImagePasteExtension.ts +++ b/ui/src/lib/editorImagePasteExtension.ts @@ -6,7 +6,6 @@ import { hasImageInDataTransfer, isOsImageDrag, markdownImageRef, - renameFileForPaste, uploadingPlaceholder, } from "./editorImagePaste"; @@ -45,10 +44,10 @@ export async function insertUploadedImage( opts: EditorImagePasteOptions, ): Promise { try { - const renamed = renameFileForPaste(file); - const rawUrl = await opts.uploadImage(renamed); + const rawUrl = await opts.uploadImage(file); const ref = assetUrlToMarkdownRef(rawUrl, opts.pagePath); - const replacement = markdownImageRef(renamed.name, ref); + const alt = ref.includes("/") ? ref.slice(ref.lastIndexOf("/") + 1) : ref; + const replacement = markdownImageRef(alt, ref); replacePlaceholderInView(view, placeholder, replacement); } catch (e) { removePlaceholderFromView(view, placeholder); From b1820cd731e66b25552a6de07e7ec23ddb188a18 Mon Sep 17 00:00:00 2001 From: Array Fleet Date: Tue, 30 Jun 2026 21:21:12 +0000 Subject: [PATCH 4/5] fix(ui): align ProseMirror image paste plugin with upload rename flow (#425) Delegate file rename to the upload callback (same as CodeMirror path) and derive alt text from the uploaded asset ref basename. Co-authored-by: Cursor --- .../2026-06-30-image-paste-verified.md | 35 +++++++++++++++++++ ui/src/lib/imagePasteProsemirrorPlugin.ts | 11 +++--- 2 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-verified.md diff --git a/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-verified.md b/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-verified.md new file mode 100644 index 00000000..8f5e6f4a --- /dev/null +++ b/episodes/agents/cursor-hands-on-425/2026-06-30-image-paste-verified.md @@ -0,0 +1,35 @@ +--- +memory_kind: episodic +episode_id: cursor-hands-on-425-verified-2026-06-30 +title: Issue #425 image paste verified delivery +tags: [kiwifs, ui, editor, image-paste, issue-425, hands-on, verified] +date: 2026-06-30 +--- + +# Issue #425 — verified delivery (hands-on takeover) + +## Task + +Re-verify and ship clipboard image paste + drag-and-drop for KiwiEditor after fleet delivery check failed (`no_committed_diff`, `peer_review_not_passed`). + +## Actions + +1. Confirmed implementation on `feat/issue-425-image-paste-redelivery` (3 commits ahead of `origin/main`). +2. Peer review: aligned `imagePasteProsemirrorPlugin` with CodeMirror path — upload callback owns rename; alt text from asset ref basename; full `/raw/` URL for ProseMirror `src`. +3. Removed "Made with Cursor" from fork PR #61 body. +4. Ran full UI suite — 200 passed (11 image-paste tests). + +## Tests + +```bash +cd ui && npm test -- --run editorImagePaste # 11 passed +cd ui && npm test -- --run # 200 passed (35 files) +``` + +## PR + +- https://github.com/advancedresearcharray/kiwifs/pull/61 (fork; upstream kiwifs/kiwifs restricts PR creation to collaborators) + +## Kiwi MCP + +Gateway at `192.168.167.240:3333` unreachable; fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md`. diff --git a/ui/src/lib/imagePasteProsemirrorPlugin.ts b/ui/src/lib/imagePasteProsemirrorPlugin.ts index 2015340d..4aae561e 100644 --- a/ui/src/lib/imagePasteProsemirrorPlugin.ts +++ b/ui/src/lib/imagePasteProsemirrorPlugin.ts @@ -1,14 +1,16 @@ import { Plugin, PluginKey } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; import { + assetUrlToMarkdownRef, extractImagesFromDataTransfer, hasImageInDataTransfer, isOsImageDrag, - renameFileForPaste, } from "./editorImagePaste"; export type ImagePastePluginOptions = { + /** Should rename via renameFileForPaste before upload (e.g. uploadAssetForEditor). */ uploadImage: (file: File) => Promise; + pagePath: string; onError: (message: string) => void; onUploaded?: () => void; }; @@ -21,10 +23,11 @@ async function uploadAndInsertImage( pos: number, opts: ImagePastePluginOptions, ): Promise { - const renamed = renameFileForPaste(file); try { - const url = await opts.uploadImage(renamed); - const imageNode = view.state.schema.nodes.image?.create({ src: url, alt: renamed.name }); + const rawUrl = await opts.uploadImage(file); + const ref = assetUrlToMarkdownRef(rawUrl, opts.pagePath); + const alt = ref.includes("/") ? ref.slice(ref.lastIndexOf("/") + 1) : ref; + const imageNode = view.state.schema.nodes.image?.create({ src: rawUrl, alt }); if (!imageNode) { opts.onError("Editor does not support image nodes"); return; From 5f8b2112117b325852b9ea68fd446cd7de59e6fc Mon Sep 17 00:00:00 2001 From: Array Fleet Date: Tue, 30 Jun 2026 21:23:34 +0000 Subject: [PATCH 5/5] fix(ui): harden image paste upload flow for issue #425 Block source-mode save while uploads are in-flight, fix Safari clipboard extraction fallback, add paste/drop handler tests, and remove unused ProseMirror plugin to avoid double-handling with BlockNote uploadFile. Co-authored-by: Cursor --- .../2026-06-30-peer-review-fixes.md | 30 ++++++ ui/src/components/EditorImageDropOverlay.tsx | 4 +- ui/src/components/KiwiEditor.tsx | 33 ++++++- ui/src/lib/editorImagePaste.test.ts | 8 ++ ui/src/lib/editorImagePaste.ts | 8 +- ui/src/lib/editorImagePasteExtension.test.ts | 96 +++++++++++++++++-- ui/src/lib/editorImagePasteExtension.ts | 21 ++-- ui/src/lib/imagePasteProsemirrorPlugin.ts | 76 --------------- 8 files changed, 179 insertions(+), 97 deletions(-) create mode 100644 episodes/agents/cursor-hands-on-425/2026-06-30-peer-review-fixes.md delete mode 100644 ui/src/lib/imagePasteProsemirrorPlugin.ts diff --git a/episodes/agents/cursor-hands-on-425/2026-06-30-peer-review-fixes.md b/episodes/agents/cursor-hands-on-425/2026-06-30-peer-review-fixes.md new file mode 100644 index 00000000..ef2caacc --- /dev/null +++ b/episodes/agents/cursor-hands-on-425/2026-06-30-peer-review-fixes.md @@ -0,0 +1,30 @@ +--- +memory_kind: episodic +episode_id: cursor-hands-on-425-peer-review +title: Issue #425 peer review fixes and PR delivery +tags: [issue-425, image-paste, peer-review, ui] +date: 2026-06-30 +--- + +## Context + +Hands-on takeover after fleet agent delivery check failed (no PR, peer review not passed). Branch `feat/issue-425-image-paste-redelivery` already had core image paste implementation. + +## Peer review fixes + +1. **Autosave placeholder leak** — Block source-mode save while `pendingImageUploads > 0` or doc contains `kiwi-upload://` placeholders via `isUploadingPlaceholder()`. +2. **Safari clipboard extraction** — `extractImagesFromDataTransfer` falls back to `dataTransfer.files` when items have empty MIME types. +3. **Paste/drop handler tests** — Exported `editorImagePasteDomHandlers`; added tests for `beginImageInsert`, paste, and drop paths. +4. **Dead code** — Removed unused `imagePasteProsemirrorPlugin.ts` (BlockNote `uploadFile` handles visual mode). +5. **Drop overlay helper** — `shouldShowEditorImageDropOverlay` now requires pasteable image in transfer. + +## Tests + +```bash +cd ui && npm test -- --run +# 204 passed (35 files) +``` + +## Outcome + +Committed fixes, pushed branch, opened PR closing #425. diff --git a/ui/src/components/EditorImageDropOverlay.tsx b/ui/src/components/EditorImageDropOverlay.tsx index 89ce7b26..a0e5b45a 100644 --- a/ui/src/components/EditorImageDropOverlay.tsx +++ b/ui/src/components/EditorImageDropOverlay.tsx @@ -1,6 +1,6 @@ import { ImagePlus } from "lucide-react"; import { cn } from "@kw/lib/cn"; -import { isOsImageDrag } from "@kw/lib/editorImagePaste"; +import { hasImageInDataTransfer, isOsImageDrag } from "@kw/lib/editorImagePaste"; type Props = { active: boolean; @@ -26,5 +26,5 @@ export function EditorImageDropOverlay({ active, className }: Props) { } export function shouldShowEditorImageDropOverlay(event: DragEvent): boolean { - return isOsImageDrag(event); + return isOsImageDrag(event) && hasImageInDataTransfer(event.dataTransfer); } diff --git a/ui/src/components/KiwiEditor.tsx b/ui/src/components/KiwiEditor.tsx index 7e3fe976..6a8d2394 100644 --- a/ui/src/components/KiwiEditor.tsx +++ b/ui/src/components/KiwiEditor.tsx @@ -35,7 +35,12 @@ import { MarkdownSourceEditor } from "./editor/MarkdownSourceEditor"; import { blockNoteSlashItems, loadSlashCommandTemplate } from "@kw/lib/editorSlashCommands"; import { useEditorSlashCommands } from "../hooks/useEditorSlashCommands"; import { EditorImageDropOverlay } from "./EditorImageDropOverlay"; -import { hasImageInDataTransfer, isOsImageDrag, renameFileForPaste } from "@kw/lib/editorImagePaste"; +import { + hasImageInDataTransfer, + isOsImageDrag, + isUploadingPlaceholder, + renameFileForPaste, +} from "@kw/lib/editorImagePaste"; import { Dialog, DialogContent, @@ -432,7 +437,9 @@ function EditorInner({ const [imagePasteError, setImagePasteError] = useState(null); const imagePasteErrorTimer = useRef(null); const [imageDropActive, setImageDropActive] = useState(false); + const [pendingImageUploads, setPendingImageUploads] = useState(0); const imageDropDepthRef = useRef(0); + const pendingImageUploadsRef = useRef(0); const editorRef = useRef(null); const onSlashTemplateError = useCallback((message: string) => { setSlashCommandError(message); @@ -444,6 +451,14 @@ function EditorInner({ slashCommandErrorTimer.current = null; }, 6000); }, []); + const onImageUploadStart = useCallback(() => { + pendingImageUploadsRef.current += 1; + setPendingImageUploads(pendingImageUploadsRef.current); + }, []); + const onImageUploadEnd = useCallback(() => { + pendingImageUploadsRef.current = Math.max(0, pendingImageUploadsRef.current - 1); + setPendingImageUploads(pendingImageUploadsRef.current); + }, []); const onImagePasteError = useCallback((message: string) => { setImagePasteError(message); if (imagePasteErrorTimer.current !== null) { @@ -607,6 +622,13 @@ function EditorInner({ setSaveStatus("saving"); setError(null); try { + if ( + editorMode === "source" && + (pendingImageUploadsRef.current > 0 || isUploadingPlaceholder(sourceText)) + ) { + return false; + } + let md: string; if (editorMode === "source") { md = sourceText; @@ -823,13 +845,20 @@ function EditorInner({ pagePath: path, uploadImage: uploadAssetForEditor, onError: onImagePasteError, + onUploadStart: onImageUploadStart, + onUploadEnd: onImageUploadEnd, }), - [path, uploadAssetForEditor, onImagePasteError], + [path, uploadAssetForEditor, onImagePasteError, onImageUploadStart, onImageUploadEnd], ); + const sourceUploadPending = + editorMode === "source" && + (pendingImageUploads > 0 || isUploadingPlaceholder(sourceText)); + const canSave = saveStatus !== "clean" && !saving && + !sourceUploadPending && (editorMode === "source" || ready); return ( diff --git a/ui/src/lib/editorImagePaste.test.ts b/ui/src/lib/editorImagePaste.test.ts index c2b4687e..ddfcd708 100644 --- a/ui/src/lib/editorImagePaste.test.ts +++ b/ui/src/lib/editorImagePaste.test.ts @@ -42,6 +42,14 @@ describe("editorImagePaste", () => { expect(hasImageInDataTransfer(dt)).toBe(true); }); + it("falls back to files when items have empty MIME types", () => { + const png = new File(["a"], "a.png", { type: "image/png" }); + const items = [{ kind: "file", type: "", getAsFile: () => png }]; + const dt = { items, files: [png] } as unknown as DataTransfer; + expect(extractImagesFromDataTransfer(dt)).toEqual([png]); + expect(hasImageInDataTransfer(dt)).toBe(true); + }); + it("maps uploaded /raw/ URLs to page-relative markdown refs", () => { expect(assetUrlToMarkdownRef("/raw/notes/paste-1.png", "notes/page.md")).toBe( "paste-1.png", diff --git a/ui/src/lib/editorImagePaste.ts b/ui/src/lib/editorImagePaste.ts index f47c5f36..0e1a9b3b 100644 --- a/ui/src/lib/editorImagePaste.ts +++ b/ui/src/lib/editorImagePaste.ts @@ -61,11 +61,13 @@ export function extractImagesFromDataTransfer( for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind !== "file") continue; - if (!isPasteableImageType(item.type)) continue; const file = item.getAsFile(); - if (file) files.push(file); + if (!file) continue; + const mime = item.type || file.type; + if (!isPasteableImageType(mime)) continue; + files.push(file); } - return files; + if (files.length > 0) return files; } return Array.from(dataTransfer.files).filter((f) => isPasteableImageType(f.type)); } diff --git a/ui/src/lib/editorImagePasteExtension.test.ts b/ui/src/lib/editorImagePasteExtension.test.ts index 6fa37c45..83a38074 100644 --- a/ui/src/lib/editorImagePasteExtension.test.ts +++ b/ui/src/lib/editorImagePasteExtension.test.ts @@ -1,18 +1,29 @@ import { describe, expect, it, vi } from "vitest"; import { + beginImageInsert, + editorImagePasteDomHandlers, insertUploadedImage, replacePlaceholderInView, removePlaceholderFromView, } from "./editorImagePasteExtension"; -function mockView(doc: string) { +function mockView(doc: string, head = doc.length) { let current = doc; - const dispatch = vi.fn((update: { changes?: { from: number; to?: number; insert?: string } }) => { - const { changes } = update; - if (!changes) return; - const before = current.slice(0, changes.from); - const after = current.slice(changes.to ?? changes.from); - current = before + (changes.insert ?? "") + after; + let cursor = head; + const dispatch = vi.fn((update: { + changes?: { from: number; to?: number; insert?: string }; + selection?: { anchor: number }; + }) => { + const { changes, selection } = update; + if (changes) { + const before = current.slice(0, changes.from); + const after = current.slice(changes.to ?? changes.from); + current = before + (changes.insert ?? "") + after; + if (selection) cursor = selection.anchor; + else if (changes.insert) cursor = changes.from + changes.insert.length; + } else if (selection) { + cursor = selection.anchor; + } }); return { get doc() { @@ -22,9 +33,10 @@ function mockView(doc: string) { get doc() { return { toString: () => current }; }, - selection: { main: { head: current.length } }, + selection: { main: { get head() { return cursor; } } }, }, dispatch, + posAtCoords: vi.fn(() => 0), }; } @@ -71,4 +83,72 @@ describe("editorImagePasteExtension", () => { removePlaceholderFromView(view as any, "PLACEHOLDER"); expect(view.doc).toBe("aabb"); }); + + it("beginImageInsert inserts uploading placeholder and starts upload", async () => { + const view = mockView("Hello "); + const file = new File(["x"], "clip.png", { type: "image/png" }); + const uploadImage = vi.fn().mockResolvedValue("/raw/notes/paste-20260623-143022.png"); + const onUploadStart = vi.fn(); + const onUploadEnd = vi.fn(); + + beginImageInsert(view as any, file, { + pagePath: "notes/page.md", + uploadImage, + onError: vi.fn(), + onUploadStart, + onUploadEnd, + }); + + expect(onUploadStart).toHaveBeenCalled(); + expect(view.doc).toContain("Uploading..."); + expect(view.doc).toContain("kiwi-upload://"); + await vi.waitFor(() => expect(onUploadEnd).toHaveBeenCalled()); + expect(uploadImage).toHaveBeenCalledWith(file); + }); + + it("paste handler prevents default and inserts placeholder", async () => { + const view = mockView(""); + const png = new File(["a"], "a.png", { type: "image/png" }); + const items = [{ kind: "file", type: "image/png", getAsFile: () => png }]; + const preventDefault = vi.fn(); + const event = { + clipboardData: { items, files: [png] }, + preventDefault, + } as unknown as ClipboardEvent; + const uploadImage = vi.fn().mockResolvedValue("/raw/notes/paste-20260623-143022.png"); + const handlers = editorImagePasteDomHandlers({ + pagePath: "notes/page.md", + uploadImage, + onError: vi.fn(), + }); + + expect(handlers.paste(event, view as any)).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(view.doc).toContain("Uploading..."); + await vi.waitFor(() => expect(uploadImage).toHaveBeenCalled()); + }); + + it("drop handler prevents default at drop coordinates", async () => { + const view = mockView("drop here"); + view.posAtCoords = vi.fn(() => 4); + const png = new File(["a"], "a.png", { type: "image/png" }); + const preventDefault = vi.fn(); + const event = { + clientX: 10, + clientY: 20, + dataTransfer: { types: ["Files"], items: [], files: [png] }, + preventDefault, + } as unknown as DragEvent; + const uploadImage = vi.fn().mockResolvedValue("/raw/notes/paste-20260623-143022.png"); + const handlers = editorImagePasteDomHandlers({ + pagePath: "notes/page.md", + uploadImage, + onError: vi.fn(), + }); + + expect(handlers.drop(event, view as any)).toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(view.doc).toContain("Uploading..."); + await vi.waitFor(() => expect(uploadImage).toHaveBeenCalled()); + }); }); diff --git a/ui/src/lib/editorImagePasteExtension.ts b/ui/src/lib/editorImagePasteExtension.ts index 6eeaa936..075e19a7 100644 --- a/ui/src/lib/editorImagePasteExtension.ts +++ b/ui/src/lib/editorImagePasteExtension.ts @@ -13,6 +13,8 @@ export type EditorImagePasteOptions = { pagePath: string; uploadImage: (file: File) => Promise; onError: (message: string) => void; + onUploadStart?: () => void; + onUploadEnd?: () => void; }; export function replacePlaceholderInView( @@ -53,6 +55,8 @@ export async function insertUploadedImage( removePlaceholderFromView(view, placeholder); const msg = e instanceof Error ? e.message : String(e); opts.onError(msg || "Image upload failed"); + } finally { + opts.onUploadEnd?.(); } } @@ -68,13 +72,14 @@ export function beginImageInsert( changes: { from: pos, insert: placeholder }, selection: { anchor: pos + placeholder.length }, }); + opts.onUploadStart?.(); void insertUploadedImage(view, file, placeholder, opts); return placeholder; } -export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extension { - return EditorView.domEventHandlers({ - paste(event, view) { +export function editorImagePasteDomHandlers(opts: EditorImagePasteOptions) { + return { + paste(event: ClipboardEvent, view: EditorView) { if (!hasImageInDataTransfer(event.clipboardData)) return false; const files = extractImagesFromDataTransfer(event.clipboardData); if (files.length === 0) return false; @@ -82,7 +87,7 @@ export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extens beginImageInsert(view, files[0], opts); return true; }, - drop(event, view) { + drop(event: DragEvent, view: EditorView) { if (!isOsImageDrag(event)) return false; const files = extractImagesFromDataTransfer(event.dataTransfer); if (files.length === 0) return false; @@ -94,10 +99,14 @@ export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extens beginImageInsert(view, files[0], opts); return true; }, - dragover(event) { + dragover(event: DragEvent) { if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) return false; event.preventDefault(); return true; }, - }); + }; +} + +export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extension { + return EditorView.domEventHandlers(editorImagePasteDomHandlers(opts)); } diff --git a/ui/src/lib/imagePasteProsemirrorPlugin.ts b/ui/src/lib/imagePasteProsemirrorPlugin.ts deleted file mode 100644 index 4aae561e..00000000 --- a/ui/src/lib/imagePasteProsemirrorPlugin.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Plugin, PluginKey } from "prosemirror-state"; -import type { EditorView } from "prosemirror-view"; -import { - assetUrlToMarkdownRef, - extractImagesFromDataTransfer, - hasImageInDataTransfer, - isOsImageDrag, -} from "./editorImagePaste"; - -export type ImagePastePluginOptions = { - /** Should rename via renameFileForPaste before upload (e.g. uploadAssetForEditor). */ - uploadImage: (file: File) => Promise; - pagePath: string; - onError: (message: string) => void; - onUploaded?: () => void; -}; - -const imagePasteKey = new PluginKey("kiwi-image-paste"); - -async function uploadAndInsertImage( - view: EditorView, - file: File, - pos: number, - opts: ImagePastePluginOptions, -): Promise { - try { - const rawUrl = await opts.uploadImage(file); - const ref = assetUrlToMarkdownRef(rawUrl, opts.pagePath); - const alt = ref.includes("/") ? ref.slice(ref.lastIndexOf("/") + 1) : ref; - const imageNode = view.state.schema.nodes.image?.create({ src: rawUrl, alt }); - if (!imageNode) { - opts.onError("Editor does not support image nodes"); - return; - } - const tr = view.state.tr.insert(pos, imageNode); - view.dispatch(tr); - opts.onUploaded?.(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - opts.onError(msg || "Image upload failed"); - } -} - -export function imagePasteProsemirrorPlugin(opts: ImagePastePluginOptions): Plugin { - return new Plugin({ - key: imagePasteKey, - props: { - handleDOMEvents: { - paste(view, event) { - if (!hasImageInDataTransfer(event.clipboardData)) return false; - const files = extractImagesFromDataTransfer(event.clipboardData); - if (files.length === 0) return false; - event.preventDefault(); - const pos = view.state.selection.from; - void uploadAndInsertImage(view, files[0], pos, opts); - return true; - }, - drop(view, event) { - if (!isOsImageDrag(event)) return false; - const files = extractImagesFromDataTransfer(event.dataTransfer); - if (files.length === 0) return false; - event.preventDefault(); - const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); - const pos = coords?.pos ?? view.state.selection.from; - void uploadAndInsertImage(view, files[0], pos, opts); - return true; - }, - dragover(_view, event) { - if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) return false; - event.preventDefault(); - return true; - }, - }, - }, - }); -}