diff --git a/src/components/Editor.css b/src/components/Editor.css index e19add14..6b1709b8 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -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 { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index bd8ad4ea..ad70cc4e 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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; entries: VaultEntry[]; onNavigateWikilink: (target: string) => void; onChange?: () => void }) { +function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: { editor: ReturnType; 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(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 ( -
+
+ {isDragOver && ( +
+
Drop image here
+
+ )} { - 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('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((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} />
)} diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts new file mode 100644 index 00000000..31ebdb61 --- /dev/null +++ b/src/hooks/useImageDrop.test.ts @@ -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() + 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) + }) + }) +}) diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts new file mode 100644 index 00000000..55cdf3d0 --- /dev/null +++ b/src/hooks/useImageDrop.ts @@ -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 { + 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('save_image', { + vaultPath, + filename: file.name, + data: base64, + }) + return convertFileSrc(savedPath) + } + return new Promise((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 + 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 } +}