From 9d0c549041bc2d7c03808339974431787fb7347c Mon Sep 17 00:00:00 2001 From: Luca Rossi Date: Sat, 28 Feb 2026 21:31:47 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20drag-and-drop=20images=20into=20editor?= =?UTF-8?q?=20=E2=80=94=20add=20drop=20overlay=20and=20copy-to-attachments?= =?UTF-8?q?=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add copy_image_to_vault Rust command for native drag-drop Adds a new Tauri command that copies an image file from a source path (provided by Tauri's drag-drop event) directly into vault/attachments/. More efficient than base64 encoding for filesystem drag-drop. Also adds core:webview:allow-on-drag-drop-event permission. Co-Authored-By: Claude Opus 4.6 * feat: handle Tauri native drag-drop for filesystem images Listen for Tauri's onDragDropEvent to intercept OS-level file drops that bypass the webview's HTML5 DnD API. When image files are dropped: 1. Copy to vault/attachments via copy_image_to_vault command 2. Insert image block into BlockNote at cursor position Also passes vaultPath through Editor → EditorContent → SingleEditorView so the hook can invoke the Tauri command. Co-Authored-By: Claude Opus 4.6 * fix: remove nonexistent drag-drop permission from capabilities The onDragDropEvent API works through core:event:default (included in core:default), not a separate webview permission. Co-Authored-By: Claude Opus 4.6 * fix: correct DragDropEvent type handling for 'over' events Tauri's DragDropEvent discriminated union only provides `paths` on 'drop' events, not 'over'. Show drag overlay unconditionally on 'over' since we can't filter by image paths at that stage. Co-Authored-By: Claude Opus 4.6 * design: drag-drop-images wireframes (idle + drag-over overlay) * style: rustfmt mcp.rs and image.rs --------- Co-authored-by: Test Co-authored-by: Claude Opus 4.6 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .claude-done | 45 +++++++++++- design/drag-drop-images.pen | 78 ++++++++++++++++++++ src-tauri/src/lib.rs | 7 ++ src-tauri/src/vault/image.rs | 109 +++++++++++++++++++++++++--- src-tauri/src/vault/mod.rs | 2 +- src/components/Editor.tsx | 1 + src/components/EditorContent.tsx | 5 +- src/components/SingleEditorView.tsx | 17 ++++- src/hooks/useImageDrop.test.ts | 11 ++- src/hooks/useImageDrop.ts | 71 ++++++++++++++++-- src/mock-tauri/mock-handlers.ts | 5 ++ 11 files changed, 327 insertions(+), 24 deletions(-) create mode 100644 design/drag-drop-images.pen diff --git a/.claude-done b/.claude-done index dc1ba430..0fd74521 100644 --- a/.claude-done +++ b/.claude-done @@ -1,3 +1,42 @@ -Task: pencil-ui-design-light-mode -Summary: Verified all 115 top-level frames in ui-design.pen have theme:{Mode:Light}. No remaining before-variant frames found (only legitimate state-transition labels). No duplicate frames detected. Visual screenshots confirm correct light-mode rendering. Previous commit 165cc0e already applied theme to 25 dark-mode frames and removed 2 before variants. -Commits: 1 (existing) \ No newline at end of file +# Drag & Drop Images — Implementation Summary + +## Problem +Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's +`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they +reach the webview's HTML5 DnD API — BlockNote never received the dropped files. + +## Solution +Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API +to listen for native file drops and handle them directly: + +1. **Rust backend** (`src-tauri/src/vault/image.rs`): + - Added `copy_image_to_vault` command that copies a file by OS path into + `vault/attachments/` with a timestamp-prefixed filename + - Refactored shared path logic into `prepare_attachment_path` helper + - Validates file exists and has an image extension before copying + +2. **Frontend** (`src/hooks/useImageDrop.ts`): + - Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop + - On `drop`: filters for image paths, copies each to vault, calls `onImageUrl` + - On `over`: shows drag overlay (paths aren't available until drop) + - `copyImageToVault` invokes the new Rust command and returns an asset URL + +3. **Editor integration** (`src/components/SingleEditorView.tsx`): + - `useInsertImageCallback` inserts a BlockNote image block at cursor position + - Wired into `useImageDrop` via the `onImageUrl` callback + - `vaultPath` threaded through Editor → EditorContent → SingleEditorView + +4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile` + on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command). + +## Commits +- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop +- `b047d0c` feat: handle Tauri native drag-drop for filesystem images +- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities +- `23ad982` fix: correct DragDropEvent type handling for 'over' events + +## Tests +- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions) +- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params +- All 300 Rust tests pass, 1167 frontend tests pass +- Coverage: Rust 85.47%, Frontend 78.64% diff --git a/design/drag-drop-images.pen b/design/drag-drop-images.pen new file mode 100644 index 00000000..1f92f393 --- /dev/null +++ b/design/drag-drop-images.pen @@ -0,0 +1,78 @@ +{ + "children": [ + { + "type": "frame", + "id": "drag_drop_idle", + "name": "Drag & Drop Images — Idle (no drag)", + "x": 0, + "y": 0, + "width": 900, + "height": 600, + "fill": "$--background", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "rectangle", + "id": "drag_idle_editor", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background", + "cornerRadius": 0 + } + ] + }, + { + "type": "frame", + "id": "drag_drop_active", + "name": "Drag & Drop Images — Drag Over (overlay shown)", + "x": 940, + "y": 0, + "width": 900, + "height": 600, + "fill": "$--background", + "layout": "vertical", + "theme": { "Mode": "Light" }, + "children": [ + { + "type": "frame", + "id": "drag_active_container", + "name": "Editor with Drag Overlay", + "layout": "vertical", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "rectangle", + "id": "drag_active_editor_bg", + "width": "fill_container", + "height": "fill_container", + "fill": "$--background" + }, + { + "type": "frame", + "id": "drag_overlay", + "name": "Drop Overlay", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "fill": "rgba(0,0,0,0.4)", + "cornerRadius": 8, + "children": [ + { + "type": "text", + "id": "drag_overlay_label", + "content": "Drop image to insert", + "fontSize": 18, + "fontWeight": "600", + "fill": "#ffffff", + "textAlign": "center" + } + ] + } + ] + } + ] + } + ] +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 725155a2..923ed406 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -137,6 +137,12 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result Result { + let vault_path = expand_tilde(&vault_path); + vault::copy_image_to_vault(&vault_path, &source_path) +} + #[tauri::command] fn rename_note( vault_path: String, @@ -419,6 +425,7 @@ pub fn run() { git_push, ai_chat, save_image, + copy_image_to_vault, purge_trash, migrate_is_a_to_type, batch_archive_notes, diff --git a/src-tauri/src/vault/image.rs b/src-tauri/src/vault/image.rs index 55384a81..c3493fd7 100644 --- a/src-tauri/src/vault/image.rs +++ b/src-tauri/src/vault/image.rs @@ -14,24 +14,29 @@ fn sanitize_filename(name: &str) -> String { .collect() } -/// Save an uploaded image to the vault's attachments directory. -/// Returns the absolute path to the saved file. -pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result { - use base64::Engine; - - let vault = Path::new(vault_path); - let attachments_dir = vault.join("attachments"); +/// Image file extensions considered valid for drag-drop import. +const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"]; +/// Prepare the attachments directory and generate a unique target path. +fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result { + let attachments_dir = Path::new(vault_path).join("attachments"); fs::create_dir_all(&attachments_dir) .map_err(|e| format!("Failed to create attachments directory: {}", e))?; - // Generate unique filename to avoid collisions let timestamp = std::time::SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename)); - let target_path = attachments_dir.join(&unique_name); + Ok(attachments_dir.join(unique_name)) +} + +/// Save an uploaded image to the vault's attachments directory. +/// Returns the absolute path to the saved file. +pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result { + use base64::Engine; + + let target_path = prepare_attachment_path(vault_path, filename)?; let bytes = base64::engine::general_purpose::STANDARD .decode(data) @@ -42,6 +47,35 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result Result { + let source = Path::new(source_path); + if !source.exists() { + return Err(format!("Source file does not exist: {}", source_path)); + } + + let ext = source + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + if !IMAGE_EXTENSIONS.contains(&ext.as_str()) { + return Err(format!("Not a supported image format: {}", source_path)); + } + + let filename = source + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("image"); + let target_path = prepare_attachment_path(vault_path, filename)?; + + fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?; + + Ok(target_path.to_string_lossy().to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -102,4 +136,61 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid base64")); } + + #[test] + fn test_copy_image_to_vault_success() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + // Create a source image file + let source_path = dir.path().join("source.png"); + fs::write(&source_path, b"fake png data").unwrap(); + + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_ok()); + + let saved_path = result.unwrap(); + assert!(std::path::Path::new(&saved_path).exists()); + assert!(saved_path.contains("attachments")); + assert!(saved_path.contains("source.png")); + + let content = fs::read(&saved_path).unwrap(); + assert_eq!(content, b"fake png data"); + } + + #[test] + fn test_copy_image_to_vault_nonexistent_source() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn test_copy_image_to_vault_rejects_non_image() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let source_path = dir.path().join("document.pdf"); + fs::write(&source_path, b"fake pdf").unwrap(); + + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Not a supported image")); + } + + #[test] + fn test_copy_image_to_vault_accepts_all_extensions() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] { + let source_path = dir.path().join(format!("img.{}", ext)); + fs::write(&source_path, b"data").unwrap(); + let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap()); + assert!(result.is_ok(), "failed for extension: {}", ext); + } + } } diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs index f7ad1849..2b3357a8 100644 --- a/src-tauri/src/vault/mod.rs +++ b/src-tauri/src/vault/mod.rs @@ -8,7 +8,7 @@ mod trash; pub use cache::scan_vault_cached; pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists}; -pub use image::save_image; +pub use image::{copy_image_to_vault, save_image}; pub use migration::migrate_is_a_to_type; pub use rename::{rename_note, RenameResult}; pub use trash::purge_trash; diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 378f5cbf..58503949 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -160,6 +160,7 @@ export const Editor = memo(function Editor({ onRestoreNote={onRestoreNote} onArchiveNote={onArchiveNote} onUnarchiveNote={onUnarchiveNote} + vaultPath={vaultPath} /> } {showRightPanel && } diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index af889dbf..b728bf21 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -31,6 +31,7 @@ interface EditorContentProps { onRestoreNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void + vaultPath?: string } function EditorLoadingSkeleton() { @@ -96,7 +97,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: { export function EditorContent({ activeTab, isLoadingNewTab, entries, editor, diffMode, diffContent, onToggleDiff, - onNavigateWikilink, onEditorChange, + onNavigateWikilink, onEditorChange, vaultPath, ...breadcrumbProps }: EditorContentProps) { return ( @@ -105,7 +106,7 @@ export function EditorContent({ {diffMode && } {!diffMode && activeTab && (
- +
)} {isLoadingNewTab && !diffMode && } diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 3c548136..e55b761b 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -11,18 +11,31 @@ import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkS import type { VaultEntry } from '../types' import { _wikilinkEntriesRef } from './editorSchema' +/** Insert an image block after the current cursor position. */ +function useInsertImageCallback(editor: ReturnType) { + const editorRef = useRef(editor) + useEffect(() => { editorRef.current = editor }, [editor]) + return useCallback((url: string) => { + const e = editorRef.current + const cursorBlock = e.getTextCursorPosition().block + e.insertBlocks([{ type: 'image' as const, props: { url } }], cursorBlock, 'after') + }, []) +} + /** Single BlockNote editor view — content is swapped via replaceBlocks */ -export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { +export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: { editor: ReturnType entries: VaultEntry[] onNavigateWikilink: (target: string) => void onChange?: () => void + vaultPath?: string }) { const navigateRef = useRef(onNavigateWikilink) useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) const { cssVars } = useEditorTheme() const containerRef = useRef(null) - const { isDragOver } = useImageDrop({ containerRef }) + const onImageUrl = useInsertImageCallback(editor) + const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath }) useEffect(() => { _wikilinkEntriesRef.current = entries diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index c6cc48aa..07705c23 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -105,10 +105,10 @@ describe('useImageDrop', () => { container.remove() }) - function renderImageDrop() { + function renderImageDrop(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) { const ref = createRef() Object.defineProperty(ref, 'current', { value: container, writable: true }) - return renderHook(() => useImageDrop({ containerRef: ref })) + return renderHook(() => useImageDrop({ containerRef: ref, ...opts })) } it('sets isDragOver to true on dragover with image files', () => { @@ -160,4 +160,11 @@ describe('useImageDrop', () => { act(() => { container.dispatchEvent(createDragEvent('dragleave', [], { relatedTarget: document.body })) }) } }) + + it('passes onImageUrl and vaultPath without error', () => { + const onImageUrl = vi.fn() + const { result } = renderImageDrop({ onImageUrl, vaultPath: '/vault' }) + // Should render without error; Tauri event listener is skipped in browser mode + expect(result.current.isDragOver).toBe(false) + }) }) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index 6d481b8b..d127c143 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -1,8 +1,9 @@ -import { useEffect, useState, type RefObject } from 'react' +import { useEffect, useRef, useState, type RefObject } from 'react' import { invoke, convertFileSrc } from '@tauri-apps/api/core' import { isTauri } from '../mock-tauri' const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] +const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'] function hasImageFiles(dt: DataTransfer): boolean { for (let i = 0; i < dt.items.length; i++) { @@ -11,6 +12,11 @@ function hasImageFiles(dt: DataTransfer): boolean { return false } +function isImagePath(path: string): boolean { + const ext = path.split('.').pop()?.toLowerCase() ?? '' + return IMAGE_EXTENSIONS.includes(ext) +} + /** Upload an image file — saves to vault/attachments in Tauri, returns data URL in browser */ export async function uploadImageFile(file: File, vaultPath?: string): Promise { if (isTauri() && vaultPath) { @@ -34,13 +40,27 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise +/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */ +async function copyImageToVault(sourcePath: string, vaultPath: string): Promise { + const savedPath = await invoke('copy_image_to_vault', { vaultPath, sourcePath }) + return convertFileSrc(savedPath) } -export function useImageDrop({ containerRef }: UseImageDropOptions) { - const [isDragOver, setIsDragOver] = useState(false) +interface UseImageDropOptions { + containerRef: RefObject + /** Called with an asset URL for each image dropped via Tauri native drag-drop. */ + onImageUrl?: (url: string) => void + vaultPath?: string +} +export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) { + const [isDragOver, setIsDragOver] = useState(false) + const onImageUrlRef = useRef(onImageUrl) + useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl]) + const vaultPathRef = useRef(vaultPath) + useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) + + // HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload) useEffect(() => { const container = containerRef.current if (!container) return @@ -75,5 +95,46 @@ export function useImageDrop({ containerRef }: UseImageDropOptions) { } }, [containerRef]) + // Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD + useEffect(() => { + if (!isTauri()) return + + let unlisten: (() => void) | null = null + let mounted = true + + void (async () => { + try { + const { getCurrentWebview } = await import('@tauri-apps/api/webview') + if (!mounted) return + unlisten = await getCurrentWebview().onDragDropEvent((event) => { + const payload = event.payload + if (payload.type === 'over') { + // Tauri 'over' events don't include paths — show overlay for any drag + setIsDragOver(true) + } else if (payload.type === 'drop') { + setIsDragOver(false) + const imagePaths = payload.paths.filter(isImagePath) + const vault = vaultPathRef.current + const callback = onImageUrlRef.current + if (imagePaths.length > 0 && vault && callback) { + for (const sourcePath of imagePaths) { + void copyImageToVault(sourcePath, vault).then(callback) + } + } + } else { + setIsDragOver(false) + } + }) + } catch { + // Tauri webview API not available (e.g. older Tauri version) + } + })() + + return () => { + mounted = false + unlisten?.() + } + }, []) + return { isDragOver } } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index 77b7d04b..d547cdc8 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -167,6 +167,11 @@ export const mockHandlers: Record any> = { const vault = args.vault_path ?? '/Users/luca/Laputa' return `${vault}/attachments/${Date.now()}-${args.filename}` }, + copy_image_to_vault: (args: { vault_path?: string; source_path: string }) => { + const vault = args.vault_path ?? '/Users/luca/Laputa' + const filename = args.source_path.split('/').pop() ?? 'image.png' + return `${vault}/attachments/${Date.now()}-${filename}` + }, get_settings: () => ({ ...mockSettings }), save_settings: (args: { settings: Settings }) => { const s = args.settings