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 `![Uploading...](kiwi-upload://uuid)`, uploads via callback, replaces with `![name](relative-path)` 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 `![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/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("![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); + }); + + it("detects uploading placeholders in source markdown", () => { + expect(isUploadingPlaceholder("![Uploading...](kiwi-upload://abc)")).toBe(true); + expect(isUploadingPlaceholder("![done](paste-20260623-143022.png)")).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 `![${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..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 ![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("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; +}