fix: disable Tauri native drag-drop to unblock HTML5 DnD (tabs, blocks)

Root cause: Tauri's dragDropEnabled (default: true) intercepts drag events
at the webview level, preventing HTML5 DnD from working for tab reorder
and BlockNote block handle drag. Setting dragDropEnabled: false lets all
drag events flow through the standard DOM API.

Image drops now use the HTML5 drop handler + uploadImageFile (same as
paste-upload) instead of Tauri's onDragDropEvent + copyImageToVault.
Removed the useInternalDragFlag workaround and padding hack.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-15 22:37:12 +01:00
parent a333a631d9
commit 19830c1734
5 changed files with 107 additions and 210 deletions

View File

@@ -20,7 +20,8 @@
"fullscreen": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"backgroundColor": "#F7F6F3"
"backgroundColor": "#F7F6F3",
"dragDropEnabled": false
}
],
"security": {

View File

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

View File

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

View File

@@ -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<string> {
if (isTauri() && vaultPath) {
@@ -40,54 +34,28 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
})
}
/** 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)
}
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** 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 }
}

View File

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