diff --git a/episodes/agents/cursor-agent-2026-07-03/2026-07-03-issue-425-sync-fix.md b/episodes/agents/cursor-agent-2026-07-03/2026-07-03-issue-425-sync-fix.md
new file mode 100644
index 00000000..829f8fce
--- /dev/null
+++ b/episodes/agents/cursor-agent-2026-07-03/2026-07-03-issue-425-sync-fix.md
@@ -0,0 +1,39 @@
+---
+memory_kind: episodic
+episode_id: cursor-agent-2026-07-03-issue-425-sync-fix
+title: Issue #425 source-mode React sync + visual drop dead zone
+tags: [ui, editor, image-paste, issue-425]
+date: 2026-07-03
+---
+
+## Task
+
+Verify and complete kiwifs/kiwifs#425 (image paste from clipboard in editor) on branch `feat/issue-425-image-paste-clipboard`.
+
+## Findings
+
+Base branch had core paste/upload flow but was missing peer-review fixes from `feat/issue-425-image-paste-hands-on-v2`:
+
+1. **Source-mode React sync** — CodeMirror programmatic `view.dispatch()` for paste placeholders did not call `onDocChange`, leaving controlled `sourceText` stale and breaking save gating / autosave.
+2. **Visual-mode wrapper drops** — Drops on editor padding (outside BlockNote surface) were ignored; wrapper only reset overlay.
+
+## Changes applied
+
+- `editorImagePasteExtension.ts` — added `onDocChange` callback + `syncDoc()` after placeholder insert/replace/remove.
+- `editorVisualImageDrop.ts` — new handler for visual-mode wrapper dead-zone drops via BlockNote API.
+- `KiwiEditor.tsx` — wired `onDocChange: handleSourceTextChange`, `handleEditorDrop` for visual drops.
+- Tests: `editorVisualImageDrop.test.ts` (new), `onDocChange` regression in `editorImagePasteExtension.test.ts`.
+
+## Tests
+
+```bash
+cd ui && npm test -- editorImagePaste editorVisualImageDrop
+# 19 passed
+
+cd ui && npm test
+# 208 passed
+```
+
+## Kiwi depot
+
+MCP gateway unavailable; HTTP to 192.168.167.240:3333 unreachable. Fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md` already documents the full solution.
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-hands-on-425/2026-07-03-final-delivery.md b/episodes/agents/cursor-hands-on-425/2026-07-03-final-delivery.md
new file mode 100644
index 00000000..ca333613
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/2026-07-03-final-delivery.md
@@ -0,0 +1,36 @@
+---
+memory_kind: episodic
+episode_id: cursor-hands-on-425-2026-07-03-final
+title: Issue #425 image paste — final hands-on delivery
+tags: [kiwifs, issue-425, image-paste, editor, verified, peer-review]
+date: 2026-07-03
+---
+
+## Context
+
+Hands-on takeover after fleet engineer failed delivery check (`no_committed_diff`, `peer_review_not_passed`). Prior cursor implementation existed on `feat/issue-425-image-paste-clipboard` with 7 code commits; fleet agent ran unrelated Go tests and produced zero source edits.
+
+## Verification
+
+Branch: `feat/issue-425-image-paste-clipboard` (11 commits ahead of `main` @ 51d68eb)
+
+```bash
+cd ui && npm test -- --run editorImagePaste
+# 16 passed (2 files)
+
+cd ui && npm test -- --run
+# 205 passed (35 files)
+```
+
+## Change this run
+
+- Added regression test for `isUploadingPlaceholder()` (save gating guard)
+- Confirmed peer-review fixes: upload gating before `setSaveStatus("saving")`, single rename path, BlockNote-only visual paste
+
+## PR
+
+https://github.com/advancedresearcharray/kiwifs/pull/76 — Closes kiwifs/kiwifs#425
+
+## Kiwi depot
+
+MCP gateway and HTTP to 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-07-03-fleet-engineer-takeover.md b/episodes/agents/cursor-hands-on-425/2026-07-03-fleet-engineer-takeover.md
new file mode 100644
index 00000000..07aca87a
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/2026-07-03-fleet-engineer-takeover.md
@@ -0,0 +1,37 @@
+---
+memory_kind: episodic
+episode_id: cursor-hands-on-425-2026-07-03-fleet-engineer
+title: Issue #425 fleet engineer takeover — verify, push, PR
+tags: [kiwifs, issue-425, image-paste, fleet-takeover, delivery]
+date: 2026-07-03
+---
+
+## Task
+
+Hands-on takeover after fleet engineer failed delivery check (`no_committed_diff`, `peer_review_not_passed`). Verify image paste implementation on `feat/issue-425-image-paste-clipboard`, run tests, commit verification log, push, open PR closing #425.
+
+## Verification
+
+Branch `feat/issue-425-image-paste-clipboard` is 8 commits ahead of `main` (51d68eb). Implementation includes:
+
+- `editorImagePaste.ts` — shared MIME helpers, paste filenames, clipboard extraction
+- `editorImagePasteExtension.ts` — CodeMirror paste/drop with uploading placeholder
+- `EditorImageDropOverlay.tsx` — dashed drag-over overlay
+- `KiwiEditor.tsx` — BlockNote upload wrapper, save gating, error toast
+- `MarkdownSourceEditor.tsx` — `imagePaste` prop wiring
+
+```bash
+cd ui && npm test -- --run editorImagePaste
+# 15 passed (2 files)
+
+cd ui && npm test -- --run
+# 204 passed (35 files)
+```
+
+## Outcome
+
+Tests green. Pushed `feat/issue-425-image-paste-clipboard` to `fork`. Fork PR: https://github.com/advancedresearcharray/kiwifs/pull/76 (upstream `kiwifs/kiwifs` restricts PR creation to collaborators).
+
+## Kiwi depot
+
+MCP gateway unavailable; HTTP to 192.168.167.240:3333 unreachable. Durable fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md`.
diff --git a/episodes/agents/cursor-hands-on-425/2026-07-03-image-paste-delivery.md b/episodes/agents/cursor-hands-on-425/2026-07-03-image-paste-delivery.md
new file mode 100644
index 00000000..c82fed7c
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/2026-07-03-image-paste-delivery.md
@@ -0,0 +1,37 @@
+---
+memory_kind: episodic
+episode_id: cursor-hands-on-425-2026-07-03
+title: Issue #425 image paste redelivery from main
+tags: [issue-425, image-paste, ui, delivery]
+date: 2026-07-03
+---
+
+## Context
+
+Issue #425 (clipboard image paste in KiwiEditor) had prior PRs closed without merge. Re-delivered on branch `feat/issue-425-image-paste-delivery` from `main` by cherry-picking the verified implementation and peer-review fixes from `feat/issue-425-image-paste-redelivery`.
+
+## Root cause (historical)
+
+No clipboard/drag image handlers existed in CodeMirror source mode; BlockNote visual mode lacked standardized paste filenames and upload error handling.
+
+## Solution delivered
+
+- Shared helpers: `editorImagePaste.ts` (MIME detection, `paste-YYYYMMDD-HHMMSS.ext` naming, Safari clipboard fallback)
+- CodeMirror: `editorImagePasteExtension.ts` (paste/drop, uploading placeholder, error callback)
+- BlockNote: `uploadFile` wrapper with rename, failed-block removal, error toast
+- Drop overlay: `EditorImageDropOverlay.tsx`
+- Save gating: block source save while uploads in-flight or `kiwi-upload://` placeholders remain
+
+## Tests
+
+```bash
+cd ui && npm test -- src/lib/editorImagePaste.test.ts src/lib/editorImagePasteExtension.test.ts
+# 15 passed
+
+cd ui && npm test
+# 204 passed (35 files)
+```
+
+## Outcome
+
+Branch ready for fleet publish (push + PR closing #425). Kiwi MCP gateway unreachable at 192.168.167.240:3333; fix doc at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md` (local, gitignored).
diff --git a/episodes/agents/cursor-hands-on-425/2026-07-03-peer-review-save-gating.md b/episodes/agents/cursor-hands-on-425/2026-07-03-peer-review-save-gating.md
new file mode 100644
index 00000000..6691400e
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/2026-07-03-peer-review-save-gating.md
@@ -0,0 +1,30 @@
+---
+memory_kind: episodic
+episode_id: cursor-hands-on-425-2026-07-03-peer-review
+title: Issue #425 peer review save gating fix
+tags: [ui, editor, image-paste, peer-review, issue-425]
+date: 2026-07-03
+---
+
+## Context
+
+Hands-on takeover for kiwifs/kiwifs#425 after fleet delivery check failed (`peer_review_not_passed`).
+
+## Peer review finding
+
+Bugbot flagged save status stuck on `"saving"` when upload gating rejected save: `setSaveStatus("saving")` ran before the upload-pending guard.
+
+## Fix
+
+Moved upload gating check in `KiwiEditor.onSaveRef` to run before `setSaving(true)` / `setSaveStatus("saving")`.
+
+## Verification
+
+```bash
+cd ui && npm test
+# 204 passed (35 files)
+```
+
+## PR
+
+https://github.com/advancedresearcharray/kiwifs/pull/66
diff --git a/episodes/agents/cursor-hands-on-425/2026-07-03-verified-delivery.md b/episodes/agents/cursor-hands-on-425/2026-07-03-verified-delivery.md
new file mode 100644
index 00000000..b6d2408e
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/2026-07-03-verified-delivery.md
@@ -0,0 +1,33 @@
+---
+memory_kind: episodic
+episode_id: cursor-hands-on-425-2026-07-03-verified
+title: Issue #425 hands-on verified delivery
+tags: [ui, editor, image-paste, issue-425, verified]
+date: 2026-07-03
+---
+
+## Task
+
+Hands-on takeover for kiwifs/kiwifs#425 after fleet engineer delivery check failed. Verify peer-review fixes, run tests, commit, push, open PR.
+
+## Verification
+
+Branch `feat/issue-425-image-paste-clipboard`, commit `3128d88`:
+
+- `onDocChange` sync in `editorImagePasteExtension.ts` — present and tested
+- `handleVisualImageDrop` in `editorVisualImageDrop.ts` — wired from `KiwiEditor.handleEditorDrop`
+- MIME allowlist + clipboard fallback in `extractImagesFromDataTransfer()`
+
+## Tests
+
+```bash
+cd ui && npm test -- editorImagePaste editorVisualImageDrop
+# 19 passed (3 files)
+
+cd ui && npm test
+# 208 passed (36 files)
+```
+
+## Outcome
+
+All acceptance criteria met. Pushed branch and opened PR closing #425.
diff --git a/episodes/agents/cursor-hands-on-425/issue-425-image-paste-clipboard-fix-doc.md b/episodes/agents/cursor-hands-on-425/issue-425-image-paste-clipboard-fix-doc.md
new file mode 100644
index 00000000..bcf4c850
--- /dev/null
+++ b/episodes/agents/cursor-hands-on-425/issue-425-image-paste-clipboard-fix-doc.md
@@ -0,0 +1,82 @@
+---
+memory_kind: semantic
+doc_id: kiwifs-kiwifs-issue-425-image-paste
+title: Image paste from clipboard in KiwiEditor
+tags: [ui, editor, image-paste, clipboard, codemirror, blocknote]
+repo: kiwifs/kiwifs
+issue_number: 425
+languages: [TypeScript]
+status: completed
+date: 2026-07-03
+---
+
+## Problem
+
+Users could not paste or drag images from the clipboard into KiwiEditor. Source (CodeMirror) mode had no paste/drop handlers. Visual (BlockNote) mode had `uploadFile` but lacked standardized paste filenames, upload error handling, and save gating during in-flight uploads.
+
+## Root cause
+
+- CodeMirror source editor had no `paste`/`drop` event handlers for image MIME types.
+- BlockNote visual mode relied on default upload without `paste-YYYYMMDD-HHMMSS.ext` naming or user-visible error feedback.
+- No shared utilities for clipboard extraction (including Safari fallback via `dataTransfer.files`).
+
+## Solution
+
+1. **`editorImagePaste.ts`** — Shared helpers: MIME allowlist (PNG/JPEG/GIF/WebP), `pasteImageFileName()`, `renameFileForPaste()`, `extractImagesFromDataTransfer()` with items-then-files fallback, uploading placeholder tokens (`kiwi-upload://`), and `assetUrlToMarkdownRef()` for page-relative refs.
+
+2. **`editorImagePasteExtension.ts`** — CodeMirror `EditorView.domEventHandlers` for paste/drop/dragover. Inserts ``, uploads via callback, replaces with `` or removes placeholder on error.
+
+3. **`EditorImageDropOverlay.tsx`** — Dashed border overlay shown during OS image drag.
+
+4. **`KiwiEditor.tsx`** — Wires `uploadAssetForEditor` (with rename), BlockNote `uploadFile` wrapper (error toast + failed block removal), drop overlay state, pending upload counter, save gating in source mode.
+
+5. **`MarkdownSourceEditor.tsx`** — Accepts `imagePaste` prop and mounts the CodeMirror extension.
+
+## Files changed
+
+| File | Change |
+|------|--------|
+| `ui/src/lib/editorImagePaste.ts` | New — shared paste/upload helpers |
+| `ui/src/lib/editorImagePasteExtension.ts` | New — CodeMirror paste/drop extension |
+| `ui/src/components/EditorImageDropOverlay.tsx` | New — drag overlay UI |
+| `ui/src/lib/editorImagePaste.test.ts` | New — 8 unit tests |
+| `ui/src/lib/editorImagePasteExtension.test.ts` | New — 7 extension tests |
+| `ui/src/components/KiwiEditor.tsx` | Modified — upload wiring, overlay, save gating |
+| `ui/src/components/editor/MarkdownSourceEditor.tsx` | Modified — image paste extension integration |
+
+## Tests
+
+```bash
+cd ui && npm test -- src/lib/editorImagePaste.test.ts src/lib/editorImagePasteExtension.test.ts
+# 15 passed
+
+cd ui && npm test
+# 204 passed (35 files)
+```
+
+## Peer review notes
+
+- Avoid double `renameFileForPaste`: source extension passes file directly to `uploadAssetForEditor` which renames once.
+- No duplicate ProseMirror paste plugin — BlockNote `uploadFile` handles visual mode.
+- Save blocked while `pendingImageUploads > 0` (both modes) or `kiwi-upload://` placeholders remain in source document.
+- Upload gating runs **before** `setSaveStatus("saving")` so autosave/Cmd+S during upload does not stick the UI in "Saving…".
+- BlockNote `uploadFile` wraps upload lifecycle with the same pending counter used by CodeMirror paste.
+- Safari clipboard: falls back from empty `items[].type` to `dataTransfer.files`.
+
+## Reuse guide
+
+To add image paste to another CodeMirror editor:
+
+```typescript
+import { editorImagePasteExtension } from "@kw/lib/editorImagePasteExtension";
+
+extensions.push(editorImagePasteExtension({
+ pagePath: "notes/page.md",
+ uploadImage: (file) => api.uploadAsset(renameFileForPaste(file), dirOf(pagePath)),
+ onError: (msg) => toast.error(msg),
+ onUploadStart: () => setPending(n => n + 1),
+ onUploadEnd: () => setPending(n => Math.max(0, n - 1)),
+}));
+```
+
+For BlockNote, pass `uploadFile` in editor options with `renameFileForPaste` applied before `api.uploadAsset`.
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-07-03-image-paste-delivery.md b/episodes/agents/cursor-issue-425/2026-07-03-image-paste-delivery.md
new file mode 100644
index 00000000..5cc23f35
--- /dev/null
+++ b/episodes/agents/cursor-issue-425/2026-07-03-image-paste-delivery.md
@@ -0,0 +1,37 @@
+---
+memory_kind: episodic
+episode_id: cursor-issue-425-2026-07-03-delivery
+title: Issue #425 image paste — cherry-pick delivery onto main
+tags: [kiwifs, issue-425, image-paste, editor, delivery]
+date: 2026-07-03
+---
+
+## Task
+
+Implement kiwifs/kiwifs#425 — image paste from clipboard in KiwiEditor (visual BlockNote + source CodeMirror), drag-and-drop, upload placeholders, error toasts.
+
+## Approach
+
+Prior delivery existed on `feat/issue-425-image-paste-delivery` (7 commits). Reset `feat/issue-425-image-paste-clipboard` to `main` and cherry-picked `d09e5ad..b60cb4c`. Resolved one modify/delete conflict on episode doc during final commit.
+
+## Verification
+
+```bash
+cd ui && npm test -- --run editorImagePaste
+# 15 passed (2 files)
+
+cd ui && npm test -- --run
+# full suite green
+```
+
+## Branch
+
+`feat/issue-425-image-paste-clipboard` (based on `main` @ 51d68eb)
+
+## Kiwi depot
+
+MCP gateway unavailable; HTTP to 192.168.167.240:3333 unreachable. Durable fix doc already at `pages/fixes/kiwifs-kiwifs/issue-425-image-paste-clipboard.md`.
+
+## Fleet handoff
+
+Local commits ready; fleet agent should push and open PR closing #425.
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..d086bb41 100644
--- a/ui/src/components/KiwiEditor.tsx
+++ b/ui/src/components/KiwiEditor.tsx
@@ -34,6 +34,14 @@ 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 { handleVisualImageDrop } from "@kw/lib/editorVisualImageDrop";
import {
Dialog,
DialogContent,
@@ -427,6 +435,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 +452,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 +477,9 @@ function EditorInner({
if (slashCommandErrorTimer.current !== null) {
window.clearTimeout(slashCommandErrorTimer.current);
}
+ if (imagePasteErrorTimer.current !== null) {
+ window.clearTimeout(imagePasteErrorTimer.current);
+ }
};
}, []);
@@ -480,14 +516,38 @@ 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) => {
+ onImageUploadStart();
+ 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;
+ } finally {
+ onImageUploadEnd();
+ }
+ },
+ [uploadAssetForEditor, onImagePasteError, onImageUploadStart, onImageUploadEnd],
+ );
+
const editorOptions = useMemo(
() => ({
uploadFile,
@@ -499,6 +559,10 @@ function EditorInner({
);
const editor = useCreateBlockNote(editorOptions);
+ useEffect(() => {
+ editorRef.current = editor ?? null;
+ }, [editor]);
+
useEffect(() => {
if (!editor) return;
const pm = (editor as any)._tiptapEditor?.view;
@@ -558,6 +622,13 @@ function EditorInner({
window.clearTimeout(autoSaveTimer.current);
autoSaveTimer.current = null;
}
+ if (
+ pendingImageUploadsRef.current > 0 ||
+ (editorMode === "source" && isUploadingPlaceholder(sourceText))
+ ) {
+ return false;
+ }
+
setSaving(true);
setSaveStatus("saving");
setError(null);
@@ -742,9 +813,73 @@ function EditorInner({
[markDirty],
);
+ const resetImageDrop = useCallback(() => {
+ imageDropDepthRef.current = 0;
+ setImageDropActive(false);
+ }, []);
+
+ const handleEditorDrop = useCallback(
+ async (e: React.DragEvent) => {
+ resetImageDrop();
+ if (editorMode !== "visual" || !editor) return;
+ await handleVisualImageDrop(editor, e.nativeEvent, uploadFile);
+ },
+ [editorMode, editor, uploadFile, resetImageDrop],
+ );
+
+ 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,
+ onDocChange: handleSourceTextChange,
+ }),
+ [
+ path,
+ uploadAssetForEditor,
+ onImagePasteError,
+ onImageUploadStart,
+ onImageUploadEnd,
+ handleSourceTextChange,
+ ],
+ );
+
+ const uploadPending =
+ pendingImageUploads > 0 ||
+ (editorMode === "source" && isUploadingPlaceholder(sourceText));
+
const canSave =
saveStatus !== "clean" &&
!saving &&
+ !uploadPending &&
(editorMode === "source" || ready);
return (
@@ -839,7 +974,14 @@ function EditorInner({
)}
-
+
+
{editorMode === "source" ? (
) : visualParseError ? (
@@ -974,6 +1117,25 @@ function EditorInner({
)}
+ {imagePasteError && (
+
+
+
+
{imagePasteError}
+
+
+
+ )}
);
}
diff --git a/ui/src/components/editor/MarkdownSourceEditor.tsx b/ui/src/components/editor/MarkdownSourceEditor.tsx
index 7a986929..fc8b27a3 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?: EditorImagePasteOptions;
};
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..9d013c71
--- /dev/null
+++ b/ui/src/lib/editorImagePaste.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import {
+ assetUrlToMarkdownRef,
+ extractImagesFromDataTransfer,
+ hasImageInDataTransfer,
+ isPasteableImageType,
+ markdownImageRef,
+ pasteImageFileName,
+ renameFileForPaste,
+ isUploadingPlaceholder,
+ 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);
+ });
+
+ it("detects uploading placeholders in source markdown", () => {
+ expect(isUploadingPlaceholder("")).toBe(true);
+ expect(isUploadingPlaceholder("")).toBe(false);
+ });
+});
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..5cc290a8
--- /dev/null
+++ b/ui/src/lib/editorImagePasteExtension.test.ts
@@ -0,0 +1,173 @@
+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("beginImageInsert calls onDocChange so React state stays in sync", async () => {
+ const view = mockView("Hello ");
+ const file = new File(["x"], "clip.png", { type: "image/png" });
+ const onDocChange = vi.fn();
+ const uploadImage = vi.fn().mockResolvedValue("/raw/notes/paste-20260623-143022.png");
+
+ beginImageInsert(view as any, file, {
+ pagePath: "notes/page.md",
+ uploadImage,
+ onError: vi.fn(),
+ onDocChange,
+ });
+
+ expect(onDocChange).toHaveBeenCalledWith(expect.stringContaining("kiwi-upload://"));
+ await vi.waitFor(() =>
+ expect(onDocChange).toHaveBeenCalledWith(expect.stringMatching(/paste-\d{8}-\d{6}\.png/)),
+ );
+ });
+
+ 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..8cff8c67
--- /dev/null
+++ b/ui/src/lib/editorImagePasteExtension.ts
@@ -0,0 +1,126 @@
+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;
+ /** Sync React state after programmatic CodeMirror updates (paste/drop placeholders). */
+ onDocChange?: (text: string) => void;
+};
+
+function syncDoc(view: EditorView, opts: EditorImagePasteOptions): void {
+ opts.onDocChange?.(view.state.doc.toString());
+}
+
+export function replacePlaceholderInView(
+ view: EditorView,
+ placeholder: string,
+ replacement: string,
+ opts?: EditorImagePasteOptions,
+): 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 },
+ });
+ if (opts) syncDoc(view, opts);
+}
+
+export function removePlaceholderFromView(
+ view: EditorView,
+ placeholder: string,
+ opts?: EditorImagePasteOptions,
+): 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: "" },
+ });
+ if (opts) syncDoc(view, opts);
+}
+
+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, opts);
+ } catch (e) {
+ removePlaceholderFromView(view, placeholder, opts);
+ 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 },
+ });
+ syncDoc(view, opts);
+ 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));
+}
diff --git a/ui/src/lib/editorVisualImageDrop.test.ts b/ui/src/lib/editorVisualImageDrop.test.ts
new file mode 100644
index 00000000..89a89615
--- /dev/null
+++ b/ui/src/lib/editorVisualImageDrop.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from "vitest";
+import { handleVisualImageDrop } from "./editorVisualImageDrop";
+
+describe("handleVisualImageDrop", () => {
+ it("returns false when drag is not an OS image", async () => {
+ const editor = {} as any;
+ const event = { dataTransfer: { types: ["text/plain"] } } as DragEvent;
+ expect(await handleVisualImageDrop(editor, event, vi.fn())).toBe(false);
+ });
+
+ it("inserts image block and uploads when dropping on wrapper dead zone", async () => {
+ const png = new File(["a"], "a.png", { type: "image/png" });
+ const block = { id: "block-1", content: ["text"] };
+ const inserted = { id: "img-1" };
+ const updateBlock = vi.fn();
+ const insertBlocks = vi.fn().mockReturnValue([inserted]);
+ const getBlock = vi.fn().mockReturnValue(block);
+ const editor = {
+ document: [block],
+ getBlock,
+ insertBlocks,
+ updateBlock,
+ prosemirrorView: {
+ posAtCoords: vi.fn().mockReturnValue(null),
+ },
+ domElement: { querySelector: vi.fn() },
+ } as any;
+ const uploadFile = vi.fn().mockResolvedValue("/raw/notes/paste.png");
+ const preventDefault = vi.fn();
+ const event = {
+ clientX: 10,
+ clientY: 20,
+ preventDefault,
+ dataTransfer: { types: ["Files"], items: [], files: [png] },
+ } as unknown as DragEvent;
+
+ expect(await handleVisualImageDrop(editor, event, uploadFile)).toBe(true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(insertBlocks).toHaveBeenCalled();
+ expect(uploadFile).toHaveBeenCalledWith(png, "img-1");
+ expect(updateBlock).toHaveBeenCalledWith("img-1", { props: { url: "/raw/notes/paste.png" } });
+ });
+});
diff --git a/ui/src/lib/editorVisualImageDrop.ts b/ui/src/lib/editorVisualImageDrop.ts
new file mode 100644
index 00000000..cd2f545d
--- /dev/null
+++ b/ui/src/lib/editorVisualImageDrop.ts
@@ -0,0 +1,98 @@
+import type { BlockNoteEditor } from "@blocknote/core";
+import {
+ extractImagesFromDataTransfer,
+ hasImageInDataTransfer,
+ isOsImageDrag,
+} from "./editorImagePaste";
+
+type UploadFileFn = (file: File, blockId?: string) => Promise;
+
+function insertImageBlockAfter(
+ editor: BlockNoteEditor,
+ referenceBlock: ReturnType,
+ fileName: string,
+ placement: "before" | "after" = "after",
+): string {
+ const imageBlock = { type: "image" as const, props: { name: fileName } };
+ if (!referenceBlock) {
+ const doc = editor.document;
+ if (doc.length === 0) {
+ throw new Error("Cannot insert image into empty document");
+ }
+ referenceBlock = doc[doc.length - 1];
+ }
+ if (
+ Array.isArray(referenceBlock.content) &&
+ referenceBlock.content.length === 0
+ ) {
+ return editor.updateBlock(referenceBlock, imageBlock).id;
+ }
+ return editor.insertBlocks([imageBlock], referenceBlock, placement)[0].id;
+}
+
+function blockIdAtDropCoords(
+ editor: BlockNoteEditor,
+ event: DragEvent,
+): { blockId: string; placement: "before" | "after" } | null {
+ const pos = editor.prosemirrorView.posAtCoords({
+ left: event.clientX,
+ top: event.clientY,
+ });
+ if (!pos) return null;
+
+ const $pos = editor.prosemirrorView.state.doc.resolve(pos.pos);
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const node = $pos.node(depth);
+ const blockId = node.attrs?.id as string | undefined;
+ if (!blockId) continue;
+ const block = editor.getBlock(blockId);
+ if (!block) continue;
+ const blockElement = editor.domElement?.querySelector(`[data-id="${blockId}"]`);
+ const blockRect = blockElement?.getBoundingClientRect();
+ const placement =
+ blockRect && (blockRect.top + blockRect.bottom) / 2 > event.clientY
+ ? "before"
+ : "after";
+ return { blockId, placement };
+ }
+ return null;
+}
+
+/** Handle image drop on the editor wrapper when BlockNote's surface does not cover the drop zone. */
+export async function handleVisualImageDrop(
+ editor: BlockNoteEditor,
+ event: DragEvent,
+ uploadFile: UploadFileFn,
+): Promise {
+ if (!isOsImageDrag(event) || !hasImageInDataTransfer(event.dataTransfer)) {
+ return false;
+ }
+ const files = extractImagesFromDataTransfer(event.dataTransfer);
+ if (files.length === 0) return false;
+
+ event.preventDefault();
+ const file = files[0];
+
+ let insertedBlockId: string;
+ const atCoords = blockIdAtDropCoords(editor, event);
+ if (atCoords) {
+ const block = editor.getBlock(atCoords.blockId)!;
+ insertedBlockId = insertImageBlockAfter(
+ editor,
+ block,
+ file.name,
+ atCoords.placement,
+ );
+ } else {
+ const lastBlock = editor.document[editor.document.length - 1];
+ insertedBlockId = insertImageBlockAfter(editor, lastBlock, file.name, "after");
+ }
+
+ try {
+ const url = await uploadFile(file, insertedBlockId);
+ editor.updateBlock(insertedBlockId, { props: { url } });
+ } catch {
+ // uploadFile removes failed blocks and surfaces toast errors
+ }
+ return true;
+}