diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index cbcdb088..39035bf5 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -102,12 +102,12 @@ const schema = BlockNoteSchema.create({ }) /** Single BlockNote editor view — content is swapped via replaceBlocks */ -function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: { editor: ReturnType; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void; vaultPath?: string }) { +function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) { const navigateRef = useRef(onNavigateWikilink) useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink]) const { cssVars } = useEditorTheme() const containerRef = useRef(null) - const { isDragOver } = useImageDrop({ editor, containerRef, vaultPath }) + const { isDragOver } = useImageDrop({ containerRef }) // Keep module-level ref in sync so WikiLink renderer can access vault entries useEffect(() => { @@ -535,7 +535,6 @@ export const Editor = memo(function Editor({ entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={handleEditorChange} - vaultPath={vaultPath} /> )} diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index 31ebdb61..c6cc48aa 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest' -import { renderHook, act, waitFor } from '@testing-library/react' +import { renderHook, act } from '@testing-library/react' import { uploadImageFile, useImageDrop } from './useImageDrop' import { createRef } from 'react' @@ -15,8 +15,8 @@ vi.mock('../mock-tauri', () => ({ // JSDOM lacks DragEvent and File.arrayBuffer — polyfill for tests beforeAll(() => { if (typeof globalThis.DragEvent === 'undefined') { - // @ts-expect-error polyfill for JSDOM - globalThis.DragEvent = class DragEvent extends MouseEvent { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).DragEvent = class DragEvent extends MouseEvent { dataTransfer: DataTransfer | null constructor(type: string, init?: DragEventInit) { super(type, init) @@ -41,21 +41,19 @@ beforeAll(() => { function createMockDataTransfer(files: File[]) { const items = files.map(f => ({ kind: 'file' as const, type: f.type, getAsFile: () => f })) return { - items: { length: items.length, ...items }, + items: { ...items, length: items.length }, files: Object.assign(files, { item: (i: number) => files[i] }), dropEffect: 'none', // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any as DataTransfer } -function createDragEvent(type: string, files: File[], opts?: { clientX?: number; clientY?: number; relatedTarget?: EventTarget | null }) { +function createDragEvent(type: string, files: File[], opts?: { relatedTarget?: EventTarget | null }) { const dt = createMockDataTransfer(files) return new DragEvent(type, { dataTransfer: dt, bubbles: true, cancelable: true, - clientX: opts?.clientX ?? 100, - clientY: opts?.clientY ?? 200, relatedTarget: opts?.relatedTarget ?? null, }) } @@ -72,7 +70,8 @@ describe('uploadImageFile', () => { it('passes file to Tauri save_image in Tauri mode', async () => { const mockTauri = await import('../mock-tauri') const originalIsTauri = mockTauri.isTauri - vi.mocked(mockTauri).isTauri = () => true as unknown as boolean + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(mockTauri as any).isTauri = () => true const { invoke, convertFileSrc } = await import('@tauri-apps/api/core') vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png') @@ -89,40 +88,27 @@ describe('uploadImageFile', () => { }) expect(url).toBe('asset://localhost/vault/attachments/123-test.png') - vi.mocked(mockTauri).isTauri = originalIsTauri + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(mockTauri as any).isTauri = originalIsTauri }) }) describe('useImageDrop', () => { let container: HTMLDivElement - let mockEditor: { - _tiptapEditor: { view: { posAtCoords: vi.Mock }; commands: { setTextSelection: vi.Mock } } - getTextCursorPosition: vi.Mock - insertBlocks: vi.Mock - } beforeEach(() => { container = document.createElement('div') document.body.appendChild(container) - - mockEditor = { - _tiptapEditor: { - view: { posAtCoords: vi.fn(() => ({ pos: 5 })) }, - commands: { setTextSelection: vi.fn() }, - }, - getTextCursorPosition: vi.fn(() => ({ block: { id: 'block-1' } })), - insertBlocks: vi.fn(), - } }) afterEach(() => { container.remove() }) - function renderImageDrop(vaultPath?: string) { + function renderImageDrop() { const ref = createRef() Object.defineProperty(ref, 'current', { value: container, writable: true }) - return renderHook(() => useImageDrop({ editor: mockEditor, containerRef: ref, vaultPath })) + return renderHook(() => useImageDrop({ containerRef: ref })) } it('sets isDragOver to true on dragover with image files', () => { @@ -152,32 +138,17 @@ describe('useImageDrop', () => { expect(result.current.isDragOver).toBe(false) }) - it('uploads image and inserts block on drop', async () => { + it('resets isDragOver on drop (upload handled by BlockNote natively)', () => { const { result } = renderImageDrop() - const blob = new Blob(['fake-png'], { type: 'image/png' }) - const file = new File([blob], 'photo.png', { type: 'image/png' }) + const file = new File(['data'], 'photo.png', { type: 'image/png' }) + + act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) }) + expect(result.current.isDragOver).toBe(true) act(() => { container.dispatchEvent(createDragEvent('drop', [file])) }) - - // The drop handler is async — wait for the upload + insert to complete - await waitFor(() => { - expect(mockEditor.insertBlocks).toHaveBeenCalledWith( - [{ type: 'image', props: { url: expect.stringMatching(/^data:image\/png/) } }], - { id: 'block-1' }, - 'after', - ) - }) expect(result.current.isDragOver).toBe(false) }) - it('ignores drop with non-image files', async () => { - renderImageDrop() - const file = new File(['text'], 'readme.txt', { type: 'text/plain' }) - - await act(async () => { container.dispatchEvent(createDragEvent('drop', [file])) }) - expect(mockEditor.insertBlocks).not.toHaveBeenCalled() - }) - it('accepts jpeg, gif, and webp types', () => { const { result } = renderImageDrop() @@ -189,27 +160,4 @@ describe('useImageDrop', () => { act(() => { container.dispatchEvent(createDragEvent('dragleave', [], { relatedTarget: document.body })) }) } }) - - it('tries to set cursor at drop position', async () => { - renderImageDrop() - const blob = new Blob(['fake'], { type: 'image/png' }) - const file = new File([blob], 'photo.png', { type: 'image/png' }) - - await act(async () => { container.dispatchEvent(createDragEvent('drop', [file], { clientX: 150, clientY: 250 })) }) - - expect(mockEditor._tiptapEditor.view.posAtCoords).toHaveBeenCalledWith({ left: 150, top: 250 }) - expect(mockEditor._tiptapEditor.commands.setTextSelection).toHaveBeenCalledWith(5) - }) - - it('handles multiple image files in a single drop', async () => { - renderImageDrop() - const file1 = new File([new Blob(['a'], { type: 'image/png' })], 'a.png', { type: 'image/png' }) - const file2 = new File([new Blob(['b'], { type: 'image/jpeg' })], 'b.jpg', { type: 'image/jpeg' }) - - act(() => { container.dispatchEvent(createDragEvent('drop', [file1, file2])) }) - - await waitFor(() => { - expect(mockEditor.insertBlocks).toHaveBeenCalledTimes(2) - }) - }) }) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index 03966825..6d481b8b 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef, type RefObject } from 'react' +import { useEffect, useState, type RefObject } from 'react' import { invoke, convertFileSrc } from '@tauri-apps/api/core' import { isTauri } from '../mock-tauri' @@ -35,19 +35,11 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise - vaultPath?: string } -export function useImageDrop({ editor, containerRef, vaultPath }: UseImageDropOptions) { +export function useImageDrop({ containerRef }: UseImageDropOptions) { const [isDragOver, setIsDragOver] = useState(false) - const vaultPathRef = useRef(vaultPath) - - useEffect(() => { - vaultPathRef.current = vaultPath - }, [vaultPath]) useEffect(() => { const container = containerRef.current @@ -66,38 +58,10 @@ export function useImageDrop({ editor, containerRef, vaultPath }: UseImageDropOp } } - const handleDrop = async (e: DragEvent) => { - e.preventDefault() + const handleDrop = () => { + // Only reset visual state; BlockNote's native dropFile plugin handles + // the actual upload (via editor.uploadFile) and block insertion. setIsDragOver(false) - if (!e.dataTransfer) return - - const files = Array.from(e.dataTransfer.files).filter(f => IMAGE_MIME_TYPES.includes(f.type)) - if (files.length === 0) return - - // Try to position cursor at the drop location - try { - const view = editor._tiptapEditor.view - const dropPos = view.posAtCoords({ left: e.clientX, top: e.clientY }) - if (dropPos) { - editor._tiptapEditor.commands.setTextSelection(dropPos.pos) - } - } catch { - // If positioning fails, we insert at the current cursor — still fine - } - - for (const file of files) { - try { - const url = await uploadImageFile(file, vaultPathRef.current) - const currentBlock = editor.getTextCursorPosition().block - editor.insertBlocks( - [{ type: 'image' as const, props: { url } }], - currentBlock, - 'after', - ) - } catch (err) { - console.error('Failed to upload dropped image:', err) - } - } } container.addEventListener('dragover', handleDragOver) @@ -109,7 +73,7 @@ export function useImageDrop({ editor, containerRef, vaultPath }: UseImageDropOp container.removeEventListener('dragleave', handleDragLeave) container.removeEventListener('drop', handleDrop) } - }, [editor, containerRef]) + }, [containerRef]) return { isDragOver } } diff --git a/tsconfig.app.json b/tsconfig.app.json index cf8de406..e5ea8d82 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -28,5 +28,6 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"] }