fix: remove duplicate image upload in useImageDrop, fix build errors

The useImageDrop hook's handleDrop was uploading files and inserting
image blocks, but BlockNote already has a native dropFile ProseMirror
plugin that does the same via editor.uploadFile — causing duplicate
uploads and image block insertions on every drop.

Fix: handleDrop now only resets the visual overlay state. BlockNote's
native handler handles the actual upload (which calls uploadImageFile)
and block insertion with proper drop-position support.

Also fix build: exclude test files from tsconfig.app.json (test files
use vi.Mock types from vitest, not available in the app build config).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-23 22:00:31 +01:00
parent 367eff34aa
commit fafa876a4d
4 changed files with 26 additions and 114 deletions

View File

@@ -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<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void; vaultPath?: string }) {
function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
const { cssVars } = useEditorTheme()
const containerRef = useRef<HTMLDivElement>(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}
/>
</div>
)}

View File

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

View File

@@ -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<s
}
interface UseImageDropOptions {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editor: any
containerRef: RefObject<HTMLDivElement | null>
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 }
}

View File

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