fix: prevent image drop overlay from breaking internal DnD and block handle

Three fixes for the image drop overlay interference:

1. Block handle clipping: Add padding (0 4px) to editor container so
   BlockNote's side menu (42px) fits within the overflow clip edge.
   overflow-y:auto forces overflow-x:auto (CSS spec), which was clipping
   the menu 2px past the container's left boundary.

2. Block handle click interference: Extract isInteractiveTarget() to
   exclude .bn-side-menu from handleContainerClick — prevents the
   container from stealing focus when clicking drag handle or add button.

3. Internal drag isolation: Track document-level dragstart/dragend to
   flag internal HTML5 drags (tabs, blocks). Tauri onDragDropEvent
   handler skips entirely during internal drags to prevent interference.
   Extract useInternalDragFlag() and handleTauriDrop() to keep
   useImageDrop under CodeScene complexity threshold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-03-15 20:53:43 +01:00
parent acd048b144
commit 698c4cece1
5 changed files with 168 additions and 132 deletions

View File

@@ -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 */

View File

@@ -22,6 +22,11 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
}, [])
}
/** 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<typeof useCreateBlockNote>
@@ -40,9 +45,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
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')

View File

@@ -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())
})
})

View File

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

View File

@@ -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 })) })
})
})