fix: drag-and-drop images into editor — add drop overlay and copy-to-attachments (#150)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* design: drag-drop-images wireframes (idle + drag-over overlay)

* style: rustfmt mcp.rs and image.rs

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Luca Rossi
2026-02-28 21:31:47 +01:00
committed by GitHub
parent aeb4f640c5
commit 9d0c549041
11 changed files with 327 additions and 24 deletions

View File

@@ -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)
# 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%

View File

@@ -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"
}
]
}
]
}
]
}
]
}

View File

@@ -137,6 +137,12 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
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,

View File

@@ -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<String, String> {
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<std::path::PathBuf, String> {
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<String, String> {
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<String
Ok(target_path.to_string_lossy().to_string())
}
/// Copy an image file from a source path into the vault's attachments directory.
/// Used for Tauri native drag-drop which provides absolute file paths.
/// Returns the absolute path to the saved file.
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
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);
}
}
}

View File

@@ -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;

View File

@@ -160,6 +160,7 @@ export const Editor = memo(function Editor({
onRestoreNote={onRestoreNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
/>
}
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -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 && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{!diffMode && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
</div>
)}
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}

View File

@@ -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<typeof useCreateBlockNote>) {
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<typeof useCreateBlockNote>
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<HTMLDivElement>(null)
const { isDragOver } = useImageDrop({ containerRef })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useEffect(() => {
_wikilinkEntriesRef.current = entries

View File

@@ -105,10 +105,10 @@ describe('useImageDrop', () => {
container.remove()
})
function renderImageDrop() {
function renderImageDrop(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
const ref = createRef<HTMLDivElement>()
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)
})
})

View File

@@ -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<string> {
if (isTauri() && vaultPath) {
@@ -34,13 +40,27 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
})
}
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */
async function copyImageToVault(sourcePath: string, vaultPath: string): Promise<string> {
const savedPath = await invoke<string>('copy_image_to_vault', { vaultPath, sourcePath })
return convertFileSrc(savedPath)
}
export function useImageDrop({ containerRef }: UseImageDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** 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 }
}

View File

@@ -167,6 +167,11 @@ export const mockHandlers: Record<string, (args: any) => 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