diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index a0530655..627f8681 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -492,6 +492,8 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola - The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. - The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. - `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags. +- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features. +- `useNativePathDrop()` is the shared Tauri file/folder-drop abstraction for text inputs that need filesystem paths instead of attachment import. It consumes native window drag/drop events, gates them to the target element bounds or focused text selection, and lets AI composer / command-palette inputs insert formatted paths at the current cursor. ### Markdown-to-BlockNote Pipeline diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1d077de8..d20756a9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -761,6 +761,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useNoteCreation` | — | Note/type creation with optimistic persistence | | `useNoteRename` | — | Note renaming and folder moves with wikilink update | | `useNoteRetargeting` | — | Shared note retargeting logic for drag/drop and command-palette actions | +| `useTauriDragDropEvent` | — | Shared native window drag/drop event subscription and cleanup | +| `useNativePathDrop` | — | Tauri-native file/folder path drops for AI and command text inputs | | `frontmatterOps` | — (pure functions) | Frontmatter CRUD: key→VaultEntry mapping, mock/Tauri dispatch | | `useTabManagement` | Navigation history, note switching | Note navigation lifecycle | | `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching | diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 97a040d4..415f4bdb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,7 @@ "titleBarStyle": "Overlay", "hiddenTitle": true, "backgroundColor": "#F7F6F3", - "dragDropEnabled": false + "dragDropEnabled": true } ], "security": { diff --git a/src/components/CommandPalette.test.tsx b/src/components/CommandPalette.test.tsx index 8966d36a..4e7f56ab 100644 --- a/src/components/CommandPalette.test.tsx +++ b/src/components/CommandPalette.test.tsx @@ -1,14 +1,45 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { act, render, screen, fireEvent, waitFor } from '@testing-library/react' import type { VaultEntry } from '../types' import { queueAiPrompt, requestOpenAiChat } from '../utils/aiPromptBridge' import { readSelectionRange } from './inlineWikilinkDom' import { CommandPalette } from './CommandPalette' import type { CommandAction } from '../hooks/useCommandRegistry' +type NativeDropPayload = { + type: string + paths: string[] + position: { x: number; y: number } +} +type NativeDropHandler = (event: { payload: NativeDropPayload }) => void +const nativeDropState = vi.hoisted(() => ({ + tauriMode: false, + handlers: {} as Record, +})) + // jsdom doesn't implement scrollIntoView Element.prototype.scrollIntoView = vi.fn() +vi.mock('../mock-tauri', () => ({ + isTauri: () => nativeDropState.tauriMode, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onDragDropEvent: vi.fn((handler: NativeDropHandler) => { + nativeDropState.handlers['native-drag-drop'] = [ + ...(nativeDropState.handlers['native-drag-drop'] ?? []), + handler, + ] + return Promise.resolve(() => { + const handlers = nativeDropState.handlers['native-drag-drop']?.filter((candidate) => candidate !== handler) ?? [] + if (handlers.length > 0) nativeDropState.handlers['native-drag-drop'] = handlers + else delete nativeDropState.handlers['native-drag-drop'] + }) + }), + }), +})) + vi.mock('../utils/aiPromptBridge', () => ({ queueAiPrompt: vi.fn(), requestOpenAiChat: vi.fn(), @@ -86,13 +117,60 @@ function updateAiInput(text: string) { return editor } +function resetNativeDropState() { + nativeDropState.tauriMode = false + for (const eventName of Object.keys(nativeDropState.handlers)) { + delete nativeDropState.handlers[eventName] + } +} + +function mockElementRect(element: HTMLElement) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 48, + width: 400, + height: 48, + toJSON: () => ({}), + }), + }) +} + +function emitNativePathDrop(paths: string[]) { + const handlers = nativeDropState.handlers['native-drag-drop'] + if (!handlers || handlers.length === 0) throw new Error('No native drop handler registered') + for (const handler of handlers) { + handler({ + payload: { + type: 'drop', + paths, + position: { x: 20, y: 20 }, + }, + }) + } +} + +async function waitForNativePathDropListener() { + await waitFor(() => { + expect(nativeDropState.handlers['native-drag-drop']?.length).toBeGreaterThan(0) + }) +} + describe('CommandPalette', () => { const onClose = vi.fn() beforeEach(() => { vi.clearAllMocks() + resetNativeDropState() }) + afterEach(resetNativeDropState) + it('renders nothing when closed', () => { const { container } = render( , @@ -284,6 +362,24 @@ describe('CommandPalette', () => { expect(screen.queryByText('Search Notes')).not.toBeInTheDocument() }) + it('inserts Tauri native folder drops into the command query input', async () => { + nativeDropState.tauriMode = true + render() + + const input = screen.getByPlaceholderText('Type a command...') as HTMLInputElement + mockElementRect(input) + input.focus() + await waitForNativePathDropListener() + + act(() => { + emitNativePathDrop(['/Users/test/Projects']) + }) + + await waitFor(() => { + expect(input).toHaveValue('/Users/test/Projects') + }) + }) + it('focuses the AI editor immediately when the leading space triggers AI mode', () => { render() diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index a5f10ae3..fe53b534 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -8,8 +8,10 @@ import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry' import { groupSortKey } from '../hooks/useCommandRegistry' import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener' import { createTranslator, type AppLocale } from '../lib/i18n' +import { formatDroppedPathList } from './inlineWikilinkDropText' import { CommandPaletteAiMode } from './CommandPaletteAiMode' import { Input } from './ui/input' +import { useNativePathDrop } from './useNativePathDrop' interface CommandPaletteProps { open: boolean @@ -117,6 +119,15 @@ function rememberCommandOpener( rememberFeedbackDialogOpener(target instanceof HTMLElement ? target : null) } +function inputSelectionRange(input: HTMLInputElement, fallbackIndex: number) { + const start = input.selectionStart ?? fallbackIndex + const end = input.selectionEnd ?? start + return { + start: Math.max(0, start), + end: Math.max(start, end), + } +} + function CommandPaletteInput({ inputRef, query, @@ -128,6 +139,27 @@ function CommandPaletteInput({ onChange: (value: string) => void placeholder: string }) { + const insertNativePathDrop = (paths: string[]) => { + const droppedPathText = formatDroppedPathList(paths) + const input = inputRef.current + if (!droppedPathText || !input) return + + const { start, end } = inputSelectionRange(input, query.length) + const nextValue = `${query.slice(0, start)}${droppedPathText}${query.slice(end)}` + const nextCursor = start + droppedPathText.length + + onChange(nextValue) + window.requestAnimationFrame(() => { + input.focus() + input.setSelectionRange(nextCursor, nextCursor) + }) + } + + useNativePathDrop({ + targetRef: inputRef, + onPathDrop: insertNativePathDrop, + }) + return ( setSelectionRange(collapseSelectionRange(nextSelectionIndex)), focusSelectionAt: (nextSelectionIndex) => focusSelectionRange(collapseSelectionRange(nextSelectionIndex)), }) - const insertTransferText = (text: string) => { - const currentSelectionRange = editorRef.current - ? readSelectionRange(editorRef.current) + const insertTransferText = (text: string, focusAfterInsert = false) => { + const editor = editorRef.current + const currentSelectionRange = editor && !focusAfterInsert + ? readSelectionRange(editor) : selectionRange const nextState = replaceInlineSelection(value, currentSelectionRange, text) + const shouldRestoreFocus = focusAfterInsert || document.activeElement === editor + onChange(nextState.value) setSelectionRange(nextState.selection) + pendingFocusAfterRemountRef.current = shouldRestoreFocus ? nextState.selection : null + forceRender((current) => current + 1) } + const insertNativePathDrop = (paths: string[]) => { + const droppedPathText = formatDroppedPathList(paths) + if (!droppedPathText) return + + insertTransferText(droppedPathText, true) + } + useNativePathDrop({ + targetRef: editorRef, + disabled, + onPathDrop: insertNativePathDrop, + }) const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE) const recoverUnsupportedMutation = () => { pendingCompositionInputRef.current = false diff --git a/src/components/WikilinkChatInput.test.tsx b/src/components/WikilinkChatInput.test.tsx index 305d58d5..3ad0b23c 100644 --- a/src/components/WikilinkChatInput.test.tsx +++ b/src/components/WikilinkChatInput.test.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { + act, createEvent, fireEvent, render, @@ -15,6 +16,32 @@ import { import { isInsertBeforeInput } from './inlineWikilinkBeforeInput' import type { VaultEntry } from '../types' +type NativeDropPayload = { + type: string + paths: string[] + position: { x: number; y: number } +} +type NativeDropHandler = (event: { payload: NativeDropPayload }) => void +const nativeDropState = vi.hoisted(() => ({ + tauriMode: false, + handlers: {} as Record, +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => nativeDropState.tauriMode, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onDragDropEvent: vi.fn((handler: NativeDropHandler) => { + nativeDropState.handlers['native-drag-drop'] = handler + return Promise.resolve(() => { + delete nativeDropState.handlers['native-drag-drop'] + }) + }), + }), +})) + const makeEntry = (overrides: Partial = {}): VaultEntry => ({ path: '/vault/note/test.md', filename: 'test.md', @@ -96,6 +123,7 @@ function setSelection(editor: HTMLElement, offset: number) { function updateEditorText(text: string) { const editor = screen.getByTestId('agent-input') + editor.focus() fireEvent.focus(editor) editor.textContent = text setSelection(editor, text.length) @@ -141,7 +169,52 @@ function createFileLikeDataTransfer({ } } +function resetNativeDropState() { + nativeDropState.tauriMode = false + for (const eventName of Object.keys(nativeDropState.handlers)) { + delete nativeDropState.handlers[eventName] + } +} + +function mockElementRect(element: HTMLElement) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 400, + bottom: 48, + width: 400, + height: 48, + toJSON: () => ({}), + }), + }) +} + +function emitNativePathDrop(paths: string[], position = { x: 20, y: 20 }) { + const handler = nativeDropState.handlers['native-drag-drop'] + if (!handler) throw new Error('No native drop handler registered') + handler({ + payload: { + type: 'drop', + paths, + position, + }, + }) +} + +async function waitForNativePathDropListener() { + await waitFor(() => { + expect(nativeDropState.handlers['native-drag-drop']).toBeDefined() + }) +} + describe('WikilinkChatInput', () => { + beforeEach(resetNativeDropState) + afterEach(resetNativeDropState) + it('renders the placeholder overlay for an empty draft', () => { render() expect(screen.getByText('Ask something...')).toBeInTheDocument() @@ -401,6 +474,50 @@ describe('WikilinkChatInput', () => { )).toBe('"/Users/test/My Folder"') }) + it('inserts Tauri native folder drops into the AI composer', async () => { + nativeDropState.tauriMode = true + const onDraftChange = vi.fn() + render() + + const editor = screen.getByTestId('agent-input') as HTMLDivElement + mockElementRect(editor) + await waitForNativePathDropListener() + updateEditorText('Open ') + editor.focus() + onDraftChange.mockClear() + + act(() => { + emitNativePathDrop(['/Users/test/My Folder']) + }) + + await waitFor(() => { + expect(onDraftChange).toHaveBeenCalledWith('Open "/Users/test/My Folder"') + }) + await waitFor(() => { + expect(screen.getByTestId('agent-input').textContent).toContain('/Users/test/My Folder') + }) + }) + + it('accepts Tauri native folder drops for the focused AI composer even when native coordinates miss', async () => { + nativeDropState.tauriMode = true + const onDraftChange = vi.fn() + render() + + const editor = screen.getByTestId('agent-input') as HTMLDivElement + mockElementRect(editor) + await waitForNativePathDropListener() + updateEditorText('Open ') + onDraftChange.mockClear() + + act(() => { + emitNativePathDrop(['/Users/test/Projects'], { x: 900, y: 900 }) + }) + + await waitFor(() => { + expect(onDraftChange).toHaveBeenCalledWith('Open /Users/test/Projects') + }) + }) + it('treats missing inputType as a non-insert beforeinput event', () => { expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow() expect(isInsertBeforeInput({} as InputEvent)).toBe(false) diff --git a/src/components/inlineWikilinkDropText.ts b/src/components/inlineWikilinkDropText.ts index eb0c2fda..c7ad4bfd 100644 --- a/src/components/inlineWikilinkDropText.ts +++ b/src/components/inlineWikilinkDropText.ts @@ -20,6 +20,15 @@ function formatDroppedPath(path: string): string { return /\s/.test(path) ? JSON.stringify(path) : path } +export function formatDroppedPathList(paths: string[]): string | null { + const cleanedPaths = paths + .map((path) => normalizeDroppedFileUrl(path.trim())) + .filter(Boolean) + + if (cleanedPaths.length === 0) return null + return cleanedPaths.map(formatDroppedPath).join(' ') +} + function normalizeDroppedTransferText(rawText: string): string | null { const trimmedText = normalizeInlineWikilinkValue(rawText).trim() if (!trimmedText) return null @@ -30,7 +39,7 @@ function normalizeDroppedTransferText(rawText: string): string | null { .filter(Boolean) if (lines.length > 0 && lines.every((line) => line.startsWith('file://'))) { - return lines.map((line) => formatDroppedPath(normalizeDroppedFileUrl(line))).join(' ') + return formatDroppedPathList(lines) } return trimmedText @@ -46,9 +55,8 @@ export function extractDroppedPathText(dataTransfer: DataTransfer): string | nul .filter((line) => line.length > 0 && !line.startsWith('#')) .map(normalizeDroppedFileUrl) - if (uriPaths.length > 0) { - return uriPaths.map(formatDroppedPath).join(' ') - } + const uriPathText = formatDroppedPathList(uriPaths) + if (uriPathText) return uriPathText const filePaths = Array.from(dataTransfer.files) .map((file) => (typeof (file as File & { path?: string }).path === 'string' @@ -56,9 +64,8 @@ export function extractDroppedPathText(dataTransfer: DataTransfer): string | nul : '')) .filter(Boolean) - if (filePaths.length > 0) { - return filePaths.map(formatDroppedPath).join(' ') - } + const filePathText = formatDroppedPathList(filePaths) + if (filePathText) return filePathText return null } diff --git a/src/components/useNativePathDrop.ts b/src/components/useNativePathDrop.ts new file mode 100644 index 00000000..4c7f1a27 --- /dev/null +++ b/src/components/useNativePathDrop.ts @@ -0,0 +1,85 @@ +import { useLayoutEffect, useRef, type RefObject } from 'react' +import { useTauriDragDropEvent, type TauriDragDropEvent } from '../hooks/useTauriDragDropEvent' + +interface NativePathDropOptions { + targetRef: RefObject + disabled?: boolean + onPathDrop: (paths: string[]) => void +} + +function pointInRect(point: { x: number; y: number }, rect: DOMRect): boolean { + return point.x >= rect.left + && point.x <= rect.right + && point.y >= rect.top + && point.y <= rect.bottom +} + +function shouldCheckScaledPoint(scale: number): boolean { + if (!Number.isFinite(scale)) return false + if (scale <= 0) return false + return scale !== 1 +} + +function nativeDropHitsTarget(target: HTMLElement, position: { x: number; y: number }): boolean { + if (!Number.isFinite(position.x) || !Number.isFinite(position.y)) return false + + const rect = target.getBoundingClientRect() + const points = [{ x: position.x, y: position.y }] + const scale = window.devicePixelRatio + if (shouldCheckScaledPoint(scale)) { + points.push({ x: position.x / scale, y: position.y / scale }) + } + + return points.some((point) => pointInRect(point, rect)) +} + +function nativeDropTargetHasFocus(target: HTMLElement): boolean { + const activeElement = document.activeElement + if (!(activeElement instanceof HTMLElement)) return false + return activeElement === target || target.contains(activeElement) +} + +function nativeDropTargetHasSelection(target: HTMLElement): boolean { + const selectionAnchor = window.getSelection()?.anchorNode + return selectionAnchor ? target === selectionAnchor || target.contains(selectionAnchor) : false +} + +function shouldHandleNativePathDrop(target: HTMLElement, event: TauriDragDropEvent): boolean { + if (event.payload.type !== 'drop') return false + return nativeDropTargetHasFocus(target) + || nativeDropTargetHasSelection(target) + || nativeDropHitsTarget(target, event.payload.position) +} + +function usableNativePaths(paths: string[]): string[] { + return paths.map((path) => path.trim()).filter(Boolean) +} + +function nativeDropPaths(event: TauriDragDropEvent): string[] | null { + if (event.payload.type !== 'drop') return null + return usableNativePaths(event.payload.paths) +} + +export function useNativePathDrop({ + targetRef, + disabled = false, + onPathDrop, +}: NativePathDropOptions) { + const disabledRef = useRef(disabled) + const onPathDropRef = useRef(onPathDrop) + + useLayoutEffect(() => { + disabledRef.current = disabled + onPathDropRef.current = onPathDrop + }, [disabled, onPathDrop]) + + useTauriDragDropEvent((event) => { + if (disabledRef.current) return + + const target = targetRef.current + if (!target || !shouldHandleNativePathDrop(target, event)) return + + const paths = nativeDropPaths(event) + if (paths && paths.length > 0) onPathDropRef.current(paths) + }) +} diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index f9fa20b1..e96a4140 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -16,16 +16,16 @@ vi.mock('../mock-tauri', () => ({ type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } } type DragDropCallback = (event: DragDropEvent) => void -const capturedDragDropHandlers: Record = {} -let nativeDropUnlisten: (eventName: string) => void = (eventName) => { - delete capturedDragDropHandlers[eventName] +let capturedDragDropHandler: DragDropCallback | undefined +let nativeDropUnlisten = () => { + capturedDragDropHandler = undefined } -vi.mock('@tauri-apps/api/webview', () => ({ - getCurrentWebview: () => ({ - listen: vi.fn((eventName: string, cb: DragDropCallback) => { - capturedDragDropHandlers[eventName] = cb - return Promise.resolve(() => nativeDropUnlisten(eventName)) +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onDragDropEvent: vi.fn((cb: DragDropCallback) => { + capturedDragDropHandler = cb + return Promise.resolve(nativeDropUnlisten) }), }), })) @@ -188,17 +188,17 @@ describe('useImageDrop — Tauri native drag-drop', () => { beforeEach(() => { tauriMode = true - nativeDropUnlisten = (eventName) => { - delete capturedDragDropHandlers[eventName] + nativeDropUnlisten = () => { + capturedDragDropHandler = undefined } - for (const eventName of Object.keys(capturedDragDropHandlers)) delete capturedDragDropHandlers[eventName] + capturedDragDropHandler = undefined container = document.createElement('div') document.body.appendChild(container) }) afterEach(() => { tauriMode = false - for (const eventName of Object.keys(capturedDragDropHandlers)) delete capturedDragDropHandlers[eventName] + capturedDragDropHandler = undefined container.remove() }) @@ -208,26 +208,23 @@ describe('useImageDrop — Tauri native drag-drop', () => { return renderHook(() => useImageDrop({ containerRef: ref, ...opts })) } - function emitNativeDropEvent(eventName: string, payload: DragDropEvent['payload']) { - const handler = capturedDragDropHandlers[eventName] - if (!handler) throw new Error(`No native drop handler registered for ${eventName}`) - handler({ payload }) + function emitNativeDropEvent(payload: DragDropEvent['payload']) { + if (!capturedDragDropHandler) throw new Error('No native drop handler registered') + capturedDragDropHandler({ payload }) } async function waitForNativeDropListeners() { await waitFor(() => { - expect(capturedDragDropHandlers['tauri://drag-drop']).toBeDefined() - expect(capturedDragDropHandlers['tauri://drag-leave']).toBeDefined() + expect(capturedDragDropHandler).toBeDefined() }) } - it('registers only native events that carry actionable drop state', async () => { + it('registers the native window drag/drop listener', async () => { const { result } = renderImageDropTauri() await waitForNativeDropListeners() expect(result.current.isDragOver).toBe(false) - expect(capturedDragDropHandlers['tauri://drag-over']).toBeUndefined() }) it('resets isDragOver on Tauri drop event', async () => { @@ -242,7 +239,7 @@ describe('useImageDrop — Tauri native drag-drop', () => { expect(result.current.isDragOver).toBe(true) act(() => { - emitNativeDropEvent('tauri://drag-drop', { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } }) + emitNativeDropEvent({ type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } }) }) expect(result.current.isDragOver).toBe(false) @@ -259,7 +256,7 @@ describe('useImageDrop — Tauri native drag-drop', () => { expect(result.current.isDragOver).toBe(true) act(() => { - emitNativeDropEvent('tauri://drag-leave', { type: 'leave', paths: [], position: { x: 0, y: 0 } }) + emitNativeDropEvent({ type: 'leave', paths: [], position: { x: 0, y: 0 } }) }) expect(result.current.isDragOver).toBe(false) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index 4e74c58a..e10e8df2 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -1,16 +1,12 @@ import { useEffect, useRef, useState, type RefObject } from 'react' import { invoke, convertFileSrc } from '@tauri-apps/api/core' -import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event' -import type { DragDropEvent as TauriDragDropPayload } from '@tauri-apps/api/webview' import { isTauri } from '../mock-tauri' +import { useTauriDragDropEvent, type TauriDragDropEvent } from './useTauriDragDropEvent' const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'] -const TAURI_DRAG_DROP_EVENT = 'tauri://drag-drop' -const TAURI_DRAG_LEAVE_EVENT = 'tauri://drag-leave' type ImageUrlHandler = (url: string) => void -type TauriDropEvent = TauriEvent function hasImageFiles(dt: DataTransfer): boolean { for (let i = 0; i < dt.items.length; i++) { @@ -66,31 +62,6 @@ function insertDroppedImages( } } -function cleanupNativeDropListeners(unlisteners: UnlistenFn[]): void { - for (const unlisten of unlisteners) { - void Promise.resolve() - .then(unlisten) - .catch(() => {}) - } -} - -async function registerNativeDropListeners( - handler: (event: TauriDropEvent) => void, -): Promise { - const { getCurrentWebview } = await import('@tauri-apps/api/webview') - const webview = getCurrentWebview() - const unlisteners: UnlistenFn[] = [] - - try { - unlisteners.push(await webview.listen(TAURI_DRAG_DROP_EVENT, handler)) - unlisteners.push(await webview.listen(TAURI_DRAG_LEAVE_EVENT, handler)) - return unlisteners - } catch (error) { - cleanupNativeDropListeners(unlisteners) - throw error - } -} - interface UseImageDropOptions { containerRef: RefObject /** Called with an asset URL for each image dropped via Tauri native drag-drop. */ @@ -98,14 +69,20 @@ interface UseImageDropOptions { vaultPath?: string } -export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) { - const [isDragOver, setIsDragOver] = useState(false) +function useLatestImageDropRefs(onImageUrl: ImageUrlHandler | undefined, vaultPath: string | undefined) { const onImageUrlRef = useRef(onImageUrl) - useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl]) const vaultPathRef = useRef(vaultPath) + + useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl]) useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) - // HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload) + return { onImageUrlRef, vaultPathRef } +} + +function useHtmlImageDropFeedback( + containerRef: RefObject, + setIsDragOver: (isDragOver: boolean) => void, +) { useEffect(() => { const container = containerRef.current if (!container) return @@ -118,16 +95,10 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr } const handleDragLeave = (e: DragEvent) => { - if (!container.contains(e.relatedTarget as Node)) { - setIsDragOver(false) - } + if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false) } - const handleDrop = () => { - // Only reset visual state; BlockNote's native dropFile plugin handles - // the actual upload (via editor.uploadFile) and block insertion. - setIsDragOver(false) - } + const handleDrop = () => setIsDragOver(false) container.addEventListener('dragover', handleDragOver) container.addEventListener('dragleave', handleDragLeave) @@ -138,42 +109,27 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr container.removeEventListener('dragleave', handleDragLeave) container.removeEventListener('drop', handleDrop) } - }, [containerRef]) + }, [containerRef, setIsDragOver]) +} - // Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD - useEffect(() => { - if (!isTauri()) return +function handleNativeImageDrop( + event: TauriDragDropEvent, + vaultPath: string | undefined, + onImageUrl: ImageUrlHandler | undefined, +): void { + if (event.payload.type !== 'drop') return + insertDroppedImages(event.payload.paths.filter(isImagePath), vaultPath, onImageUrl) +} - let unlisteners: UnlistenFn[] = [] - let mounted = true +export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) { + const [isDragOver, setIsDragOver] = useState(false) + const { onImageUrlRef, vaultPathRef } = useLatestImageDropRefs(onImageUrl, vaultPath) - void (async () => { - try { - const nextUnlisteners = await registerNativeDropListeners((event) => { - if (event.payload.type === 'drop') { - setIsDragOver(false) - insertDroppedImages( - event.payload.paths.filter(isImagePath), - vaultPathRef.current, - onImageUrlRef.current, - ) - return - } - setIsDragOver(false) - }) - if (mounted) unlisteners = nextUnlisteners - else cleanupNativeDropListeners(nextUnlisteners) - } catch { - // Tauri webview API not available (e.g. older Tauri version) - } - })() - - return () => { - mounted = false - cleanupNativeDropListeners(unlisteners) - unlisteners = [] - } - }, []) + useHtmlImageDropFeedback(containerRef, setIsDragOver) + useTauriDragDropEvent((event) => { + setIsDragOver(false) + handleNativeImageDrop(event, vaultPathRef.current, onImageUrlRef.current) + }) return { isDragOver } } diff --git a/src/hooks/useTauriDragDropEvent.ts b/src/hooks/useTauriDragDropEvent.ts new file mode 100644 index 00000000..e1c4e189 --- /dev/null +++ b/src/hooks/useTauriDragDropEvent.ts @@ -0,0 +1,48 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event' +import type { DragDropEvent as TauriDragDropPayload } from '@tauri-apps/api/window' +import { isTauri } from '../mock-tauri' + +export type TauriDragDropEvent = TauriEvent +type TauriDragDropHandler = (event: TauriDragDropEvent) => void + +function cleanupNativeDropListeners(unlisteners: UnlistenFn[]): void { + for (const unlisten of unlisteners) { + void Promise.resolve() + .then(unlisten) + .catch(() => {}) + } +} + +async function registerNativeDropListener(handler: TauriDragDropHandler): Promise { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + return getCurrentWindow().onDragDropEvent(handler) +} + +export function useTauriDragDropEvent(handler: TauriDragDropHandler) { + const handlerRef = useRef(handler) + + useLayoutEffect(() => { + handlerRef.current = handler + }, [handler]) + + useEffect(() => { + if (!isTauri()) return + + let mounted = true + let unlisteners: UnlistenFn[] = [] + + void registerNativeDropListener((event) => handlerRef.current(event)) + .then((unlisten) => { + if (mounted) unlisteners = [unlisten] + else cleanupNativeDropListeners([unlisten]) + }) + .catch(() => {}) + + return () => { + mounted = false + cleanupNativeDropListeners(unlisteners) + unlisteners = [] + } + }, []) +} diff --git a/src/utils/tauriDragDropConfig.test.ts b/src/utils/tauriDragDropConfig.test.ts new file mode 100644 index 00000000..e7c620c7 --- /dev/null +++ b/src/utils/tauriDragDropConfig.test.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs' + +describe('Tauri drag/drop configuration', () => { + it('keeps native file drops enabled for path-aware app inputs', () => { + const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8')) + + expect(config.app.windows[0].dragDropEnabled).toBe(true) + }) +})