diff --git a/src/components/Editor.css b/src/components/Editor.css index 049c1aa9..96bc7dc1 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -23,7 +23,11 @@ opacity: 0.55; } -/* BlockNote container */ +/* 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. */ .editor__blocknote-container { flex: 1; min-height: 0; @@ -32,6 +36,7 @@ justify-content: center; position: relative; cursor: text; + padding: 0 4px; } /* Drag-over state: subtle border highlight */ diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 27f0751b..d1851ed6 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -22,6 +22,11 @@ function useInsertImageCallback(editor: ReturnType) { }, []) } +/** Returns true if the click target is an interactive area the container should not steal focus from. */ +function isInteractiveTarget(target: HTMLElement): boolean { + return !!(target.closest('[contenteditable="true"]') || target.closest('.bn-side-menu')) +} + /** Single BlockNote editor view — content is swapped via replaceBlocks */ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme, editable = true }: { editor: ReturnType @@ -40,9 +45,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath }) const handleContainerClick = useCallback((e: React.MouseEvent) => { - if (!editable) return - const target = e.target as HTMLElement - if (target.closest('[contenteditable="true"]')) return + if (!editable || isInteractiveTarget(e.target as HTMLElement)) return const blocks = editor.document if (blocks.length > 0) { editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end') diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index ce1f26af..a8f80d2a 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -250,4 +250,36 @@ describe('useImageDrop — Tauri native drag-drop', () => { 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 } }, + }) + }) + expect(onImageUrl).not.toHaveBeenCalled() + + // End the internal drag + act(() => { document.dispatchEvent(new Event('dragend')) }) + + // 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()) + }) }) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index c90f13ad..214e6f98 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -53,89 +53,89 @@ interface UseImageDropOptions { 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) + } +} + 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 - const handleDragOver = (e: DragEvent) => { + const onOver = (e: DragEvent) => { if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return e.preventDefault() e.dataTransfer.dropEffect = 'copy' setIsDragOver(true) } - - const handleDragLeave = (e: DragEvent) => { - if (!container.contains(e.relatedTarget as Node)) { - setIsDragOver(false) - } + const onLeave = (e: DragEvent) => { + if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false) } + const onDrop = () => { 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) - } - - container.addEventListener('dragover', handleDragOver) - container.addEventListener('dragleave', handleDragLeave) - container.addEventListener('drop', handleDrop) - + container.addEventListener('dragover', onOver) + container.addEventListener('dragleave', onLeave) + container.addEventListener('drop', onDrop) return () => { - container.removeEventListener('dragover', handleDragOver) - container.removeEventListener('dragleave', handleDragLeave) - container.removeEventListener('drop', handleDrop) + container.removeEventListener('dragover', onOver) + container.removeEventListener('dragleave', onLeave) + container.removeEventListener('drop', onDrop) } }, [containerRef]) - // Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD + // 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((event) => { - const payload = event.payload - if (payload.type === 'over') { - // Tauri 'over' events don't include paths and can't distinguish - // OS file drags from internal drags (tabs, blocks). Let the HTML5 - // dragover handler drive isDragOver — it checks hasImageFiles(). - } else if (payload.type === 'drop') { + unlisten = await getCurrentWebview().onDragDropEvent(({ payload }) => { + if (internalDragRef.current) return + 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 { + handleTauriDrop(payload.paths, vaultPathRef.current, onImageUrlRef.current) + } else if (payload.type !== 'over') { setIsDragOver(false) } }) - } catch { - // Tauri webview API not available (e.g. older Tauri version) - } + } catch { /* Tauri webview API not available */ } })() - - return () => { - mounted = false - unlisten?.() - } - }, []) + 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 5e6e1eb2..d313c3f5 100644 --- a/tests/smoke/image-drop-overlay-fix.spec.ts +++ b/tests/smoke/image-drop-overlay-fix.spec.ts @@ -3,112 +3,108 @@ import { test, expect } from '@playwright/test' const DROP_OVERLAY = '.editor__drop-overlay' const EDITOR_CONTAINER = '.editor__blocknote-container' +async function openFirstNote(page: import('@playwright/test').Page) { + await page.goto('/') + await page.waitForLoadState('networkidle') + const noteList = page.locator('[data-testid="note-list-container"]') + await noteList.waitFor({ timeout: 5_000 }) + await noteList.locator('.cursor-pointer').first().click() + await page.waitForTimeout(300) + await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 }) +} + +/** Dispatch a DragEvent with an image file on the editor container to show the overlay. */ +async function showOverlayViaImageDragover(page: import('@playwright/test').Page) { + await page.evaluate((sel) => { + const el = document.querySelector(sel) + if (!el) throw new Error('Editor container not found') + const dt = new DataTransfer() + dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' })) + el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true })) + }, EDITOR_CONTAINER) + await expect(page.locator(DROP_OVERLAY)).toBeVisible() +} + test.describe('Image drop overlay — internal drag does not trigger overlay', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/') - await page.waitForLoadState('networkidle') - // Open the first note to mount the editor - const noteList = page.locator('[data-testid="note-list-container"]') - await noteList.waitFor({ timeout: 5_000 }) - await noteList.locator('.cursor-pointer').first().click() - await page.waitForTimeout(300) - await page.waitForSelector(EDITOR_CONTAINER, { timeout: 5_000 }) - }) + test.beforeEach(async ({ page }) => { await openFirstNote(page) }) test('internal drag (no image files) does not show the overlay', async ({ page }) => { - // Simulate an internal drag (e.g. block or tab) — dataTransfer has no files await page.locator(EDITOR_CONTAINER).first().dispatchEvent('dragover', { bubbles: true, cancelable: true, }) - - // The overlay should NOT appear for non-file drags await expect(page.locator(DROP_OVERLAY)).not.toBeVisible() }) test('dragover with image file shows the overlay', async ({ page }) => { - // Simulate an OS file drag with an image file in dataTransfer - // Playwright dispatchEvent can't set dataTransfer items directly, - // so we use page.evaluate to dispatch a proper DragEvent with file items - await page.evaluate((selector) => { - const el = document.querySelector(selector) - if (!el) throw new Error('Editor container not found') - - const dt = new DataTransfer() - dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' })) - - const event = new DragEvent('dragover', { - dataTransfer: dt, - bubbles: true, - cancelable: true, - }) - el.dispatchEvent(event) - }, EDITOR_CONTAINER) - - await expect(page.locator(DROP_OVERLAY)).toBeVisible() + await showOverlayViaImageDragover(page) await expect(page.locator(DROP_OVERLAY)).toContainText('Drop image here') }) test('dragleave after image dragover hides the overlay', async ({ page }) => { - // First show the overlay via image dragover - await page.evaluate((selector) => { - const el = document.querySelector(selector) - if (!el) throw new Error('Editor container not found') + await showOverlayViaImageDragover(page) - const dt = new DataTransfer() - dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' })) - el.dispatchEvent(new DragEvent('dragover', { - dataTransfer: dt, - bubbles: true, - cancelable: true, - })) - }, EDITOR_CONTAINER) - - await expect(page.locator(DROP_OVERLAY)).toBeVisible() - - // Now simulate dragleave (cursor left the container) - await page.evaluate((selector) => { - const el = document.querySelector(selector) - if (!el) throw new Error('Editor container not found') - - el.dispatchEvent(new DragEvent('dragleave', { - bubbles: true, - cancelable: true, - relatedTarget: document.body, - })) + await page.evaluate((sel) => { + const el = document.querySelector(sel)! + el.dispatchEvent(new DragEvent('dragleave', { bubbles: true, cancelable: true, relatedTarget: document.body })) }, EDITOR_CONTAINER) await expect(page.locator(DROP_OVERLAY)).not.toBeVisible() }) test('drop resets the overlay', async ({ page }) => { - // Show overlay via image dragover - await page.evaluate((selector) => { - const el = document.querySelector(selector) - if (!el) throw new Error('Editor container not found') + await showOverlayViaImageDragover(page) - const dt = new DataTransfer() - dt.items.add(new File(['fake'], 'photo.png', { type: 'image/png' })) - el.dispatchEvent(new DragEvent('dragover', { - dataTransfer: dt, - bubbles: true, - cancelable: true, - })) - }, EDITOR_CONTAINER) - - await expect(page.locator(DROP_OVERLAY)).toBeVisible() - - // Simulate drop - await page.evaluate((selector) => { - const el = document.querySelector(selector) - if (!el) throw new Error('Editor container not found') - - el.dispatchEvent(new DragEvent('drop', { - bubbles: true, - cancelable: true, - })) + await page.evaluate((sel) => { + const el = document.querySelector(sel)! + el.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true })) }, EDITOR_CONTAINER) await expect(page.locator(DROP_OVERLAY)).not.toBeVisible() }) }) + +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 }) => { + await openFirstNote(page) + + const noteList = page.locator('[data-testid="note-list-container"]') + const secondNote = noteList.locator('.cursor-pointer').nth(1) + if (await secondNote.count() === 0) { test.skip(); return } + await secondNote.click() + await page.waitForTimeout(300) + + await page.evaluate((sel) => { + document.dispatchEvent(new Event('dragstart', { bubbles: true })) + const el = document.querySelector(sel)! + const dt = new DataTransfer() + dt.setData('text/plain', '0') + el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true })) + }, EDITOR_CONTAINER) + + await expect(page.locator(DROP_OVERLAY)).not.toBeVisible() + + await page.evaluate(() => { document.dispatchEvent(new Event('dragend', { bubbles: true })) }) + }) +})