diff --git a/src/hooks/useNoteWikilinkDrop.test.tsx b/src/hooks/useNoteWikilinkDrop.test.tsx new file mode 100644 index 00000000..ba3564b8 --- /dev/null +++ b/src/hooks/useNoteWikilinkDrop.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useRef } from 'react' +import { clearDraggedNotePath } from '../components/note-retargeting/noteDragData' +import { useNoteWikilinkDrop } from './useNoteWikilinkDrop' + +const NOTE_DRAG_MIME = 'application/x-laputa-note-path' + +function createMockDataTransfer(seedData: Record): DataTransfer { + const data = new Map(Object.entries(seedData)) + const types = Array.from(data.keys()) + + return { + dropEffect: 'none', + effectAllowed: 'move', + setData(type: string, value: string) { + data.set(type, value) + if (!types.includes(type)) types.push(type) + }, + getData(type: string) { + return data.get(type) ?? '' + }, + clearData() { + data.clear() + types.splice(0, types.length) + }, + get types() { + return types + }, + } as DataTransfer +} + +function createDragEvent(type: 'dragover' | 'drop', dataTransfer: DataTransfer): DragEvent { + const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }) + return event +} + +function NoteDropHarness({ + onInsertTarget, + vaultPath = '/vault', +}: { + onInsertTarget: (target: string) => void + vaultPath?: string +}) { + const containerRef = useRef(null) + useNoteWikilinkDrop({ containerRef, onInsertTarget, vaultPath }) + + return ( +
+
Editor body
+
+ ) +} + +describe('useNoteWikilinkDrop', () => { + afterEach(() => { + clearDraggedNotePath() + }) + + it('claims note dragover events before editor internals handle them', () => { + render() + const container = screen.getByTestId('note-drop-container') + const child = screen.getByTestId('note-drop-child') + const editorDragOver = vi.fn() + container.addEventListener('dragover', editorDragOver) + const dataTransfer = createMockDataTransfer({ + [NOTE_DRAG_MIME]: '/vault/Projects/Alpha.md', + 'text/plain': '/vault/Projects/Alpha.md', + }) + + child.dispatchEvent(createDragEvent('dragover', dataTransfer)) + + expect(editorDragOver).not.toHaveBeenCalled() + expect(dataTransfer.dropEffect).toBe('link') + }) + + it('claims note drops before editor internals handle them', () => { + const onInsertTarget = vi.fn() + render() + const container = screen.getByTestId('note-drop-container') + const child = screen.getByTestId('note-drop-child') + const editorDrop = vi.fn() + container.addEventListener('drop', editorDrop) + + child.dispatchEvent(createDragEvent('drop', createMockDataTransfer({ + [NOTE_DRAG_MIME]: '/vault/Projects/Alpha.md', + 'text/plain': '/vault/Projects/Alpha.md', + }))) + + expect(editorDrop).not.toHaveBeenCalled() + expect(onInsertTarget).toHaveBeenCalledWith('Projects/Alpha') + }) +}) diff --git a/src/hooks/useNoteWikilinkDrop.ts b/src/hooks/useNoteWikilinkDrop.ts index fd40e4fa..6511ed2c 100644 --- a/src/hooks/useNoteWikilinkDrop.ts +++ b/src/hooks/useNoteWikilinkDrop.ts @@ -1,8 +1,9 @@ import { useEffect, useEffectEvent, type RefObject } from 'react' -import { readDraggedNotePath } from '../components/note-retargeting/noteDragData' +import { clearDraggedNotePath, readDraggedNotePath } from '../components/note-retargeting/noteDragData' import { relativePathStem } from '../utils/wikilink' const MARKDOWN_NOTE_PATH = /\.md$/i +const NOTE_DROP_EVENT_OPTIONS = { capture: true } interface UseNoteWikilinkDropOptions { containerRef: RefObject @@ -19,6 +20,12 @@ function droppedNoteWikilinkTarget(dataTransfer: DataTransfer | null, vaultPath? return relativePathStem(notePath, vaultPath) } +function claimNoteDropEvent(event: DragEvent): void { + event.preventDefault() + event.stopPropagation() + if (event.dataTransfer) event.dataTransfer.dropEffect = 'link' +} + export function useNoteWikilinkDrop({ containerRef, onInsertTarget, @@ -27,28 +34,33 @@ export function useNoteWikilinkDrop({ const handleDragOver = useEffectEvent((event: DragEvent) => { if (!droppedNoteWikilinkTarget(event.dataTransfer, vaultPath)) return - event.preventDefault() - if (event.dataTransfer) event.dataTransfer.dropEffect = 'link' + claimNoteDropEvent(event) }) const handleDrop = useEffectEvent((event: DragEvent) => { const target = droppedNoteWikilinkTarget(event.dataTransfer, vaultPath) if (!target) return - event.preventDefault() - onInsertTarget(target) + claimNoteDropEvent(event) + try { + onInsertTarget(target) + } finally { + clearDraggedNotePath() + } }) useEffect(() => { const container = containerRef.current if (!container) return - container.addEventListener('dragover', handleDragOver) - container.addEventListener('drop', handleDrop) + container.addEventListener('dragenter', handleDragOver, NOTE_DROP_EVENT_OPTIONS) + container.addEventListener('dragover', handleDragOver, NOTE_DROP_EVENT_OPTIONS) + container.addEventListener('drop', handleDrop, NOTE_DROP_EVENT_OPTIONS) return () => { - container.removeEventListener('dragover', handleDragOver) - container.removeEventListener('drop', handleDrop) + container.removeEventListener('dragenter', handleDragOver, NOTE_DROP_EVENT_OPTIONS) + container.removeEventListener('dragover', handleDragOver, NOTE_DROP_EVENT_OPTIONS) + container.removeEventListener('drop', handleDrop, NOTE_DROP_EVENT_OPTIONS) } }, [containerRef]) } diff --git a/src/main.test.ts b/src/main.test.ts index c72a1005..6676c0f7 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -104,7 +104,7 @@ describe('main entrypoint', () => { rootOptions().onCaughtError?.(error, { componentStack: '\n in App' }) expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '\n in App' }) - }, 10_000) + }, 20_000) it('normalizes missing React component stacks before handing errors to Sentry', async () => { await importEntrypoint()