diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b50e13ba..477c5658 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,8 @@ "fullscreen": false, "titleBarStyle": "Overlay", "hiddenTitle": true, - "backgroundColor": "#F7F6F3" + "backgroundColor": "#F7F6F3", + "dragDropEnabled": false } ], "security": { diff --git a/src/components/Editor.css b/src/components/Editor.css index 96bc7dc1..049c1aa9 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -23,11 +23,7 @@ opacity: 0.55; } -/* BlockNote container - padding: 0 4px prevents BlockNote's side menu (≈42px wide) from being - clipped — the menu hangs left of the editor body (40px padding), and - overflow-y:auto forces overflow-x:auto (CSS spec). The 4px padding - keeps the menu inside the padding box, which is the overflow clip edge. */ +/* BlockNote container */ .editor__blocknote-container { flex: 1; min-height: 0; @@ -36,7 +32,6 @@ justify-content: center; position: relative; cursor: text; - padding: 0 4px; } /* Drag-over state: subtle border highlight */ diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index a8f80d2a..56d1d6af 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -14,19 +14,6 @@ vi.mock('../mock-tauri', () => ({ isTauri: () => tauriMode, })) -type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } } -type DragDropCallback = (event: DragDropEvent) => void -let capturedDragDropHandler: DragDropCallback | null = null - -vi.mock('@tauri-apps/api/webview', () => ({ - getCurrentWebview: () => ({ - onDragDropEvent: vi.fn((cb: DragDropCallback) => { - capturedDragDropHandler = cb - return Promise.resolve(() => { capturedDragDropHandler = null }) - }), - }), -})) - // JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests beforeAll(() => { if (typeof globalThis.DragEvent === 'undefined') { @@ -57,7 +44,7 @@ function createMockDataTransfer(files: File[]) { const items = files.map(f => ({ kind: 'file' as const, type: f.type, getAsFile: () => f })) return { items: { ...items, length: items.length }, - files: Object.assign(files, { item: (i: number) => files[i] }), + files: Object.assign([...files], { item: (i: number) => files[i], length: files.length }), dropEffect: 'none', // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as DataTransfer @@ -149,7 +136,7 @@ describe('useImageDrop', () => { expect(result.current.isDragOver).toBe(false) }) - it('resets isDragOver on drop (upload handled by BlockNote natively)', () => { + it('resets isDragOver on drop', () => { const { result } = renderImageDrop() const file = new File(['data'], 'photo.png', { type: 'image/png' }) @@ -172,114 +159,57 @@ describe('useImageDrop', () => { } }) - it('passes onImageUrl and vaultPath without error', () => { + it('calls onImageUrl for each image file on drop', async () => { 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) - }) -}) + renderImageDrop({ onImageUrl, vaultPath: '/vault' }) -describe('useImageDrop — Tauri native drag-drop', () => { - let container: HTMLDivElement + const file = new File(['fake-img'], 'photo.png', { type: 'image/png' }) + act(() => { container.dispatchEvent(createDragEvent('drop', [file])) }) - beforeEach(() => { - tauriMode = true - capturedDragDropHandler = null - container = document.createElement('div') - document.body.appendChild(container) + // uploadImageFile is async — wait for callback + await waitFor(() => expect(onImageUrl).toHaveBeenCalledTimes(1)) + expect(onImageUrl).toHaveBeenCalledWith(expect.stringContaining('data:image/png')) }) - afterEach(() => { - tauriMode = false - capturedDragDropHandler = null - container.remove() - }) - - function renderImageDropTauri(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) { - const ref = createRef() - Object.defineProperty(ref, 'current', { value: container, writable: true }) - return renderHook(() => useImageDrop({ containerRef: ref, ...opts })) - } - - it('does not set isDragOver on Tauri over event (internal drags are indistinguishable)', async () => { - const { result } = renderImageDropTauri() - - await waitFor(() => expect(capturedDragDropHandler).not.toBeNull()) - - act(() => { - capturedDragDropHandler!({ payload: { type: 'over', paths: [], position: { x: 100, y: 100 } } }) - }) - - expect(result.current.isDragOver).toBe(false) - }) - - it('resets isDragOver on Tauri drop event', async () => { + it('skips non-image files during drop', async () => { const onImageUrl = vi.fn() - const { result } = renderImageDropTauri({ onImageUrl, vaultPath: '/vault' }) + renderImageDrop({ onImageUrl }) - await waitFor(() => expect(capturedDragDropHandler).not.toBeNull()) + const pdf = new File(['data'], 'doc.pdf', { type: 'application/pdf' }) + act(() => { container.dispatchEvent(createDragEvent('drop', [pdf])) }) - // Set isDragOver via HTML5 dragover (simulates real OS file drag) - const file = new File(['data'], 'photo.png', { type: 'image/png' }) - act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) }) - expect(result.current.isDragOver).toBe(true) - - act(() => { - capturedDragDropHandler!({ - payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } }, - }) - }) - - expect(result.current.isDragOver).toBe(false) - }) - - it('resets isDragOver on Tauri cancel event', async () => { - const { result } = renderImageDropTauri() - - await waitFor(() => expect(capturedDragDropHandler).not.toBeNull()) - - // Set isDragOver via HTML5 dragover first - const file = new File(['data'], 'photo.png', { type: 'image/png' }) - act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) }) - expect(result.current.isDragOver).toBe(true) - - act(() => { - capturedDragDropHandler!({ payload: { type: 'cancel', paths: [], position: { x: 0, y: 0 } } }) - }) - - expect(result.current.isDragOver).toBe(false) - }) - - it('ignores Tauri events during internal HTML5 drags (tab/block reorder)', async () => { - const onImageUrl = vi.fn() - renderImageDropTauri({ onImageUrl, vaultPath: '/vault' }) - - await waitFor(() => expect(capturedDragDropHandler).not.toBeNull()) - - // Simulate an internal drag start (e.g. tab drag) - act(() => { document.dispatchEvent(new Event('dragstart')) }) - - // Tauri drop event during internal drag should be ignored - act(() => { - capturedDragDropHandler!({ - payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } }, - }) - }) + // Give it a tick to ensure no callback fires + await new Promise(r => setTimeout(r, 50)) expect(onImageUrl).not.toHaveBeenCalled() + }) - // End the internal drag - act(() => { document.dispatchEvent(new Event('dragend')) }) + it('does not call onImageUrl when no callback provided', () => { + renderImageDrop() + const file = new File(['data'], 'photo.png', { type: 'image/png' }) + // Should not throw + act(() => { container.dispatchEvent(createDragEvent('drop', [file])) }) + }) - // After internal drag ends, Tauri events should work again - const { invoke } = await import('@tauri-apps/api/core') - vi.mocked(invoke).mockResolvedValue('/vault/attachments/photo.png') - act(() => { - capturedDragDropHandler!({ - payload: { type: 'drop', paths: ['/tmp/photo.png'], position: { x: 100, y: 100 } }, - }) - }) - // onImageUrl is called async (after copyImageToVault resolves) - await waitFor(() => expect(onImageUrl).toHaveBeenCalled()) + it('handles multiple image files in a single drop', async () => { + const onImageUrl = vi.fn() + renderImageDrop({ onImageUrl }) + + const files = [ + new File(['a'], 'a.png', { type: 'image/png' }), + new File(['b'], 'b.jpg', { type: 'image/jpeg' }), + ] + const dt = createMockDataTransfer(files) + const event = new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }) + act(() => { container.dispatchEvent(event) }) + + await waitFor(() => expect(onImageUrl).toHaveBeenCalledTimes(2)) + }) + + it('internal drag (no image files) does not trigger overlay', () => { + const { result } = renderImageDrop() + + // Internal drags have no image files in dataTransfer + act(() => { container.dispatchEvent(createDragEvent('dragover', [])) }) + expect(result.current.isDragOver).toBe(false) }) }) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index 214e6f98..554ce355 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -3,7 +3,6 @@ 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++) { @@ -12,11 +11,6 @@ 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) { @@ -40,54 +34,28 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise { - const savedPath = await invoke('copy_image_to_vault', { vaultPath, sourcePath }) - return convertFileSrc(savedPath) -} - interface UseImageDropOptions { containerRef: RefObject - /** Called with an asset URL for each image dropped via Tauri native drag-drop. */ + /** Called with an asset URL for each image dropped. */ onImageUrl?: (url: string) => void vaultPath?: string } -/** Track whether an internal HTML5 drag (tab, block) is in progress. - * Internal drags fire `dragstart` on a DOM element; OS file drags do not. */ -function useInternalDragFlag() { - const ref = useRef(false) - useEffect(() => { - const start = () => { ref.current = true } - const end = () => { ref.current = false } - document.addEventListener('dragstart', start) - document.addEventListener('dragend', end) - return () => { document.removeEventListener('dragstart', start); document.removeEventListener('dragend', end) } - }, []) - return ref -} - -/** Process a Tauri native file drop payload — copy images to vault. */ -function handleTauriDrop( - paths: string[], - vaultPath: string | undefined, - callback: ((url: string) => void) | undefined, -) { - const imagePaths = paths.filter(isImagePath) - if (imagePaths.length > 0 && vaultPath && callback) { - for (const p of imagePaths) void copyImageToVault(p, vaultPath).then(callback) - } -} - +/** + * Handles image drag-and-drop via HTML5 DnD events only. + * + * Native Tauri drag-drop is disabled (`dragDropEnabled: false` in tauri.conf.json) + * so that HTML5 DnD works for both internal drags (tabs, blocks) and OS file drops. + * OS-dropped image files are read through the standard DataTransfer API and saved + * via the `save_image` Tauri command (same as paste-upload). + */ 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]) - const internalDragRef = useInternalDragFlag() - // HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload) useEffect(() => { const container = containerRef.current if (!container) return @@ -101,7 +69,21 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr const onLeave = (e: DragEvent) => { if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false) } - const onDrop = () => { setIsDragOver(false) } + const onDrop = (e: DragEvent) => { + setIsDragOver(false) + if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return + // Prevent browser default (open file in tab) for OS image drops + e.preventDefault() + const callback = onImageUrlRef.current + const vault = vaultPathRef.current + if (!callback) return + for (let i = 0; i < e.dataTransfer.files.length; i++) { + const file = e.dataTransfer.files[i] + if (IMAGE_MIME_TYPES.includes(file.type)) { + void uploadImageFile(file, vault).then(callback) + } + } + } container.addEventListener('dragover', onOver) container.addEventListener('dragleave', onLeave) @@ -113,29 +95,5 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr } }, [containerRef]) - // Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD. - // Skipped entirely when an internal drag is in progress (tabs, blocks). - 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(({ payload }) => { - if (internalDragRef.current) return - if (payload.type === 'drop') { - setIsDragOver(false) - handleTauriDrop(payload.paths, vaultPathRef.current, onImageUrlRef.current) - } else if (payload.type !== 'over') { - setIsDragOver(false) - } - }) - } catch { /* Tauri webview API not available */ } - })() - return () => { mounted = false; unlisten?.() } - }, [internalDragRef]) - return { isDragOver } } diff --git a/tests/smoke/image-drop-overlay-fix.spec.ts b/tests/smoke/image-drop-overlay-fix.spec.ts index d313c3f5..215e04c9 100644 --- a/tests/smoke/image-drop-overlay-fix.spec.ts +++ b/tests/smoke/image-drop-overlay-fix.spec.ts @@ -25,7 +25,7 @@ async function showOverlayViaImageDragover(page: import('@playwright/test').Page await expect(page.locator(DROP_OVERLAY)).toBeVisible() } -test.describe('Image drop overlay — internal drag does not trigger overlay', () => { +test.describe('Image drop overlay — only shows for image drags', () => { test.beforeEach(async ({ page }) => { await openFirstNote(page) }) test('internal drag (no image files) does not show the overlay', async ({ page }) => { @@ -64,29 +64,8 @@ test.describe('Image drop overlay — internal drag does not trigger overlay', ( }) }) -test.describe('Block handle (side menu) is not clipped by editor overflow', () => { - test.beforeEach(async ({ page }) => { await openFirstNote(page) }) - - test('side menu is fully visible within container bounds on hover', async ({ page }) => { - await page.locator('.bn-block-outer').first().hover() - await page.waitForTimeout(400) - - const result = await page.evaluate(() => { - const menu = document.querySelector('[class*="sideMenu"], .bn-side-menu, [data-side-menu]') - const container = document.querySelector('.editor__blocknote-container') - if (!menu || !container) return null - const mr = menu.getBoundingClientRect() - const cr = container.getBoundingClientRect() - return { menuLeft: mr.left, containerLeft: cr.left } - }) - - expect(result).not.toBeNull() - expect(result!.menuLeft).toBeGreaterThanOrEqual(result!.containerLeft) - }) -}) - -test.describe('Tab drag does not trigger image drop overlay', () => { - test('dragging a tab over the editor does not show the overlay', async ({ page }) => { +test.describe('Tab drag works alongside image drop overlay', () => { + test('dragging a tab does not show the image drop overlay', async ({ page }) => { await openFirstNote(page) const noteList = page.locator('[data-testid="note-list-container"]') @@ -95,6 +74,7 @@ test.describe('Tab drag does not trigger image drop overlay', () => { await secondNote.click() await page.waitForTimeout(300) + // Simulate tab drag over editor — dataTransfer has text, not image files await page.evaluate((sel) => { document.dispatchEvent(new Event('dragstart', { bubbles: true })) const el = document.querySelector(sel)! @@ -107,4 +87,37 @@ test.describe('Tab drag does not trigger image drop overlay', () => { await page.evaluate(() => { document.dispatchEvent(new Event('dragend', { bubbles: true })) }) }) + + test('tab elements have draggable attribute', async ({ page }) => { + await openFirstNote(page) + const tab = page.locator('[data-tab-path]').first() + await expect(tab).toHaveAttribute('draggable', 'true') + }) +}) + +test.describe('Block handle is usable', () => { + test.beforeEach(async ({ page }) => { await openFirstNote(page) }) + + test('side menu appears on block hover', async ({ page }) => { + const blocks = page.locator('.bn-block-outer') + if (await blocks.count() === 0) { test.skip(); return } + await blocks.first().hover() + await page.waitForTimeout(400) + + const menu = page.locator('.bn-side-menu') + // Side menu should be visible and within viewport + const count = await menu.count() + if (count === 0) { test.skip(); return } + await expect(menu.first()).toBeVisible() + }) + + test('editor container does not have clipping padding', async ({ page }) => { + // dragDropEnabled:false in tauri.conf.json means we no longer need the + // padding hack that could cause side-menu misalignment + const padding = await page.evaluate((sel) => { + const el = document.querySelector(sel) + return el ? getComputedStyle(el).paddingLeft : null + }, EDITOR_CONTAINER) + expect(padding).toBe('0px') + }) })