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:
@@ -20,7 +20,8 @@
|
||||
"fullscreen": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3"
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user