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/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/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/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 ``.
+- 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/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.
diff --git a/ui/src/components/EditorImageDropOverlay.tsx b/ui/src/components/EditorImageDropOverlay.tsx
new file mode 100644
index 00000000..a0e5b45a
--- /dev/null
+++ b/ui/src/components/EditorImageDropOverlay.tsx
@@ -0,0 +1,30 @@
+import { ImagePlus } from "lucide-react";
+import { cn } from "@kw/lib/cn";
+import { hasImageInDataTransfer, 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) && hasImageInDataTransfer(event.dataTransfer);
+}
diff --git a/ui/src/components/KiwiEditor.tsx b/ui/src/components/KiwiEditor.tsx
index 0fda1e9d..6a8d2394 100644
--- a/ui/src/components/KiwiEditor.tsx
+++ b/ui/src/components/KiwiEditor.tsx
@@ -34,6 +34,13 @@ 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,
+ isUploadingPlaceholder,
+ renameFileForPaste,
+} from "@kw/lib/editorImagePaste";
import {
Dialog,
DialogContent,
@@ -427,6 +434,13 @@ 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 [pendingImageUploads, setPendingImageUploads] = useState(0);
+ const imageDropDepthRef = useRef(0);
+ const pendingImageUploadsRef = useRef(0);
+ const editorRef = useRef(null);
const onSlashTemplateError = useCallback((message: string) => {
setSlashCommandError(message);
if (slashCommandErrorTimer.current !== null) {
@@ -437,6 +451,24 @@ 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) {
+ window.clearTimeout(imagePasteErrorTimer.current);
+ }
+ imagePasteErrorTimer.current = window.setTimeout(() => {
+ setImagePasteError(null);
+ imagePasteErrorTimer.current = null;
+ }, 6000);
+ }, []);
const loadSlashTemplate = useCallback((templatePath: string) => loadSlashCommandTemplate(templatePath), []);
useEffect(() => {
@@ -444,6 +476,9 @@ function EditorInner({
if (slashCommandErrorTimer.current !== null) {
window.clearTimeout(slashCommandErrorTimer.current);
}
+ if (imagePasteErrorTimer.current !== null) {
+ window.clearTimeout(imagePasteErrorTimer.current);
+ }
};
}, []);
@@ -480,14 +515,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 +555,10 @@ function EditorInner({
);
const editor = useCreateBlockNote(editorOptions);
+ useEffect(() => {
+ editorRef.current = editor ?? null;
+ }, [editor]);
+
useEffect(() => {
if (!editor) return;
const pm = (editor as any)._tiptapEditor?.view;
@@ -562,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;
@@ -742,9 +809,56 @@ 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,
+ onUploadStart: onImageUploadStart,
+ onUploadEnd: onImageUploadEnd,
+ }),
+ [path, uploadAssetForEditor, onImagePasteError, onImageUploadStart, onImageUploadEnd],
+ );
+
+ const sourceUploadPending =
+ editorMode === "source" &&
+ (pendingImageUploads > 0 || isUploadingPlaceholder(sourceText));
+
const canSave =
saveStatus !== "clean" &&
!saving &&
+ !sourceUploadPending &&
(editorMode === "source" || ready);
return (
@@ -839,7 +953,14 @@ function EditorInner({
)}
-
+
+
{editorMode === "source" ? (
) : visualParseError ? (
@@ -974,6 +1096,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..ddfcd708
--- /dev/null
+++ b/ui/src/lib/editorImagePaste.test.ts
@@ -0,0 +1,70 @@
+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("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",
+ );
+ expect(assetUrlToMarkdownRef("/raw/paste-1.png", "page.md")).toBe("paste-1.png");
+ });
+
+ it("builds markdown image syntax", () => {
+ expect(markdownImageRef("shot", "paste-1.png")).toBe("");
+ });
+
+ 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..0e1a9b3b
--- /dev/null
+++ b/ui/src/lib/editorImagePaste.ts
@@ -0,0 +1,116 @@
+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;
+ const file = item.getAsFile();
+ if (!file) continue;
+ const mime = item.type || file.type;
+ if (!isPasteableImageType(mime)) continue;
+ files.push(file);
+ }
+ if (files.length > 0) 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 ``;
+}
+
+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 ``;
+}
+
+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..83a38074
--- /dev/null
+++ b/ui/src/lib/editorImagePasteExtension.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ beginImageInsert,
+ editorImagePasteDomHandlers,
+ insertUploadedImage,
+ replacePlaceholderInView,
+ removePlaceholderFromView,
+} from "./editorImagePasteExtension";
+
+function mockView(doc: string, head = doc.length) {
+ let current = doc;
+ 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() {
+ return current;
+ },
+ state: {
+ get doc() {
+ return { toString: () => current };
+ },
+ selection: { main: { get head() { return cursor; } } },
+ },
+ dispatch,
+ posAtCoords: vi.fn(() => 0),
+ };
+}
+
+describe("editorImagePasteExtension", () => {
+ it("replaces uploading placeholder with markdown image on success", async () => {
+ const view = mockView("Hello ");
+ const placeholder = "";
+ 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("");
+ const onError = vi.fn();
+ const file = new File(["x"], "clip.png", { type: "image/png" });
+
+ await insertUploadedImage(view as any, file, "", {
+ 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");
+ });
+
+ 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
new file mode 100644
index 00000000..075e19a7
--- /dev/null
+++ b/ui/src/lib/editorImagePasteExtension.ts
@@ -0,0 +1,112 @@
+import { EditorView } from "@codemirror/view";
+import type { Extension } from "@codemirror/state";
+import {
+ assetUrlToMarkdownRef,
+ extractImagesFromDataTransfer,
+ hasImageInDataTransfer,
+ isOsImageDrag,
+ markdownImageRef,
+ uploadingPlaceholder,
+} from "./editorImagePaste";
+
+export type EditorImagePasteOptions = {
+ pagePath: string;
+ uploadImage: (file: File) => Promise;
+ onError: (message: string) => void;
+ onUploadStart?: () => void;
+ onUploadEnd?: () => 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 rawUrl = await opts.uploadImage(file);
+ const ref = assetUrlToMarkdownRef(rawUrl, opts.pagePath);
+ const alt = ref.includes("/") ? ref.slice(ref.lastIndexOf("/") + 1) : ref;
+ const replacement = markdownImageRef(alt, 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");
+ } finally {
+ opts.onUploadEnd?.();
+ }
+}
+
+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 },
+ });
+ opts.onUploadStart?.();
+ void insertUploadedImage(view, file, placeholder, opts);
+ return placeholder;
+}
+
+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;
+ event.preventDefault();
+ beginImageInsert(view, files[0], opts);
+ return true;
+ },
+ drop(event: DragEvent, view: EditorView) {
+ 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: DragEvent) {
+ if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) return false;
+ event.preventDefault();
+ return true;
+ },
+ };
+}
+
+export function editorImagePasteExtension(opts: EditorImagePasteOptions): Extension {
+ return EditorView.domEventHandlers(editorImagePasteDomHandlers(opts));
+}