feat: add drag & drop image support in editor
Extract uploadImageFile to shared hook, add useImageDrop hook that handles dragover/dragleave/drop events on the editor container. Drops image files (jpg, png, gif, webp), uploads them via the existing save_image flow, and inserts BlockNote image blocks at the drop position. Visual feedback via drop overlay and dashed border. Refactored Editor.tsx uploadFile to use the shared function. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,35 @@
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Drag-over state: subtle border highlight */
|
||||
.editor__blocknote-container--drag-over {
|
||||
outline: 2px dashed var(--primary, #155DFF);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Drop overlay shown when dragging images over the editor */
|
||||
.editor__drop-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(21, 93, 255, 0.06);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor__drop-overlay-label {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
background: var(--background, #fff);
|
||||
color: var(--primary, #155DFF);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-container {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { filterSuggestionItems } from '@blocknote/core/extensions'
|
||||
import { createReactInlineContentSpec, useCreateBlockNote, SuggestionMenuController } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import '@blocknote/mantine/style.css'
|
||||
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import { uploadImageFile, useImageDrop } from '../hooks/useImageDrop'
|
||||
import type { VaultEntry, GitCommit } from '../types'
|
||||
import { Inspector, type FrontmatterValue } from './Inspector'
|
||||
import { AIChatPanel } from './AIChatPanel'
|
||||
@@ -103,10 +102,12 @@ const schema = BlockNoteSchema.create({
|
||||
})
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) {
|
||||
function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: { editor: ReturnType<typeof useCreateBlockNote>; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void; vaultPath?: string }) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
const { cssVars } = useEditorTheme()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { isDragOver } = useImageDrop({ editor, containerRef, vaultPath })
|
||||
|
||||
// Keep module-level ref in sync so WikiLink renderer can access vault entries
|
||||
useEffect(() => {
|
||||
@@ -156,7 +157,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
|
||||
}, [baseItems, editor])
|
||||
|
||||
return (
|
||||
<div className="editor__blocknote-container" style={cssVars as React.CSSProperties}>
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
|
||||
{isDragOver && (
|
||||
<div className="editor__drop-overlay">
|
||||
<div className="editor__drop-overlay-label">Drop image here</div>
|
||||
</div>
|
||||
)}
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
@@ -194,29 +200,7 @@ export const Editor = memo(function Editor({
|
||||
// Single editor instance — reused across all tabs
|
||||
const editor = useCreateBlockNote({
|
||||
schema,
|
||||
uploadFile: async (file: File) => {
|
||||
if (isTauri() && vaultPathRef.current) {
|
||||
// Tauri mode: save to vault/attachments and return a stable asset URL
|
||||
const buf = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
|
||||
const base64 = btoa(binary)
|
||||
const savedPath = await invoke<string>('save_image', {
|
||||
vaultPath: vaultPathRef.current,
|
||||
filename: file.name,
|
||||
data: base64,
|
||||
})
|
||||
return convertFileSrc(savedPath)
|
||||
}
|
||||
// Browser dev mode: use data URL (survives reload, acceptable for dev)
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
},
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
// Cache parsed blocks per tab path for instant switching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
|
||||
@@ -551,6 +535,7 @@ export const Editor = memo(function Editor({
|
||||
entries={entries}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onChange={handleEditorChange}
|
||||
vaultPath={vaultPath}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
215
src/hooks/useImageDrop.test.ts
Normal file
215
src/hooks/useImageDrop.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest'
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { uploadImageFile, useImageDrop } from './useImageDrop'
|
||||
import { createRef } from 'react'
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: vi.fn(),
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${path}`),
|
||||
}))
|
||||
|
||||
vi.mock('../mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
}))
|
||||
|
||||
// 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 {
|
||||
dataTransfer: DataTransfer | null
|
||||
constructor(type: string, init?: DragEventInit) {
|
||||
super(type, init)
|
||||
this.dataTransfer = init?.dataTransfer ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// File.prototype.arrayBuffer may be missing in older JSDOM
|
||||
if (!File.prototype.arrayBuffer) {
|
||||
File.prototype.arrayBuffer = function () {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||
reader.readAsArrayBuffer(this)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Mock DataTransfer (JSDOM doesn't implement it)
|
||||
function createMockDataTransfer(files: File[]) {
|
||||
const items = files.map(f => ({ kind: 'file' as const, type: f.type, getAsFile: () => f }))
|
||||
return {
|
||||
items: { length: items.length, ...items },
|
||||
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 }) {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
describe('uploadImageFile', () => {
|
||||
it('returns a data URL in browser mode', async () => {
|
||||
const blob = new Blob(['fake-image-data'], { type: 'image/png' })
|
||||
const file = new File([blob], 'test.png', { type: 'image/png' })
|
||||
|
||||
const url = await uploadImageFile(file)
|
||||
expect(url).toMatch(/^data:image\/png;base64,/)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
const { invoke, convertFileSrc } = await import('@tauri-apps/api/core')
|
||||
vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-test.png')
|
||||
vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-test.png')
|
||||
|
||||
const blob = new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' })
|
||||
const file = new File([blob], 'test.png', { type: 'image/png' })
|
||||
|
||||
const url = await uploadImageFile(file, '/vault')
|
||||
expect(invoke).toHaveBeenCalledWith('save_image', {
|
||||
vaultPath: '/vault',
|
||||
filename: 'test.png',
|
||||
data: expect.any(String),
|
||||
})
|
||||
expect(url).toBe('asset://localhost/vault/attachments/123-test.png')
|
||||
|
||||
vi.mocked(mockTauri).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) {
|
||||
const ref = createRef<HTMLDivElement>()
|
||||
Object.defineProperty(ref, 'current', { value: container, writable: true })
|
||||
return renderHook(() => useImageDrop({ editor: mockEditor, containerRef: ref, vaultPath }))
|
||||
}
|
||||
|
||||
it('sets isDragOver to true on dragover with image files', () => {
|
||||
const { result } = renderImageDrop()
|
||||
const file = new File(['data'], 'photo.png', { type: 'image/png' })
|
||||
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores dragover with non-image files', () => {
|
||||
const { result } = renderImageDrop()
|
||||
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' })
|
||||
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('resets isDragOver on dragleave when leaving container', () => {
|
||||
const { result } = renderImageDrop()
|
||||
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('dragleave', [], { relatedTarget: document.body })) })
|
||||
expect(result.current.isDragOver).toBe(false)
|
||||
})
|
||||
|
||||
it('uploads image and inserts block on drop', async () => {
|
||||
const { result } = renderImageDrop()
|
||||
const blob = new Blob(['fake-png'], { type: 'image/png' })
|
||||
const file = new File([blob], 'photo.png', { type: 'image/png' })
|
||||
|
||||
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()
|
||||
|
||||
for (const type of ['image/jpeg', 'image/gif', 'image/webp']) {
|
||||
const file = new File(['data'], `img.${type.split('/')[1]}`, { type })
|
||||
act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) })
|
||||
expect(result.current.isDragOver).toBe(true)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
112
src/hooks/useImageDrop.ts
Normal file
112
src/hooks/useImageDrop.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState, useRef, type RefObject } from 'react'
|
||||
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']
|
||||
|
||||
function hasImageFiles(dt: DataTransfer): boolean {
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
if (dt.items[i].kind === 'file' && IMAGE_MIME_TYPES.includes(dt.items[i].type)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const buf = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(buf)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
|
||||
const base64 = btoa(binary)
|
||||
const savedPath = await invoke<string>('save_image', {
|
||||
vaultPath,
|
||||
filename: file.name,
|
||||
data: base64,
|
||||
})
|
||||
return convertFileSrc(savedPath)
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
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) {
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
vaultPathRef.current = vaultPath
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
setIsDragOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
if (!container.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
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)
|
||||
container.addEventListener('dragleave', handleDragLeave)
|
||||
container.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('dragover', handleDragOver)
|
||||
container.removeEventListener('dragleave', handleDragLeave)
|
||||
container.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [editor, containerRef])
|
||||
|
||||
return { isDragOver }
|
||||
}
|
||||
Reference in New Issue
Block a user