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:
45
.claude-done
45
.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)
|
||||
# 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%
|
||||
|
||||
78
design/drag-drop-images.pen
Normal file
78
design/drag-drop-images.pen
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -160,6 +160,7 @@ export const Editor = memo(function Editor({
|
||||
onRestoreNote={onRestoreNote}
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user