From 2e7c5cb433f95d9d10939bcc38453dcc9b4e3ec2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 6 May 2026 14:47:43 +0200 Subject: [PATCH] fix: restore native image attachments --- src-tauri/tauri.conf.json | 2 +- src/components/Editor.tsx | 41 +---- src/hooks/useImageDrop.test.ts | 47 +----- src/hooks/useImageDrop.ts | 183 +++++++++------------- src/utils/tauriDragDropConfig.test.ts | 4 +- tests/smoke/clipboard-image-paste.spec.ts | 98 ------------ 6 files changed, 82 insertions(+), 293 deletions(-) delete mode 100644 tests/smoke/clipboard-image-paste.spec.ts diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b2f1e934..3702f33f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ }, "hiddenTitle": true, "backgroundColor": "#F7F6F3", - "dragDropEnabled": true + "dragDropEnabled": false } ], "security": { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 84941245..d4688991 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -3,7 +3,7 @@ import { useEditorTabSwap } from '../hooks/useEditorTabSwap' import { useCreateBlockNote } from '@blocknote/react' import '@blocknote/mantine/style.css' import 'katex/dist/katex.min.css' -import { clipboardImageFiles, uploadImageFile } from '../hooks/useImageDrop' +import { uploadImageFile } from '../hooks/useImageDrop' import { DEFAULT_AI_AGENT, type AiAgentId, type AiAgentReadiness } from '../lib/aiAgents' import type { AiTarget } from '../lib/aiTargets' import { translate, type AppLocale } from '../lib/i18n' @@ -188,40 +188,6 @@ interface EditorSetupParams { diffToggleRef?: React.MutableRefObject<() => void> } -function queueClipboardImagePasteUploads( - editor: ReturnType, - files: File[], - vaultPath?: string, -) { - const referenceBlock = editor.getTextCursorPosition().block - - for (const file of files) { - const insertedBlock = editor.insertBlocks([ - { type: 'image' as const, props: { name: file.name } }, - ], referenceBlock, 'after')[0] - - void uploadImageFile(file, vaultPath) - .then((url) => { - editor.updateBlock(insertedBlock.id, { props: { name: file.name, url } }) - }) - .catch((error) => { - console.warn('[editor] Failed to paste clipboard image:', error) - }) - } -} - -function handleClipboardImagePaste( - event: ClipboardEvent, - editor: ReturnType, - vaultPath?: string, -): boolean { - const files = clipboardImageFiles(event.clipboardData) - if (files.length === 0) return false - - queueClipboardImagePasteUploads(editor, files, vaultPath) - return true -} - function useEditorSetup({ tabs, activeTabPath, vaultPath, onContentChange, onLoadDiff, onLoadDiffAtCommit, pendingCommitDiffRequest, onPendingCommitDiffHandled, getNoteStatus, @@ -234,11 +200,6 @@ function useEditorSetup({ const editor = useCreateBlockNote({ schema, uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current), - pasteHandler: ({ event, editor, defaultPasteHandler }) => { - if (handleClipboardImagePaste(event, editor, vaultPathRef.current)) return true - - return defaultPasteHandler() - }, _tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE }, extensions: [ createImeCompositionKeyGuardExtension(), diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index 63b49518..ad24f7b8 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from 'vitest' import { renderHook, act, waitFor } from '@testing-library/react' -import { clipboardImageFiles, uploadImageFile, useImageDrop } from './useImageDrop' +import { uploadImageFile, useImageDrop } from './useImageDrop' import { createRef } from 'react' let tauriMode = false @@ -21,9 +21,9 @@ let nativeDropUnlisten = () => { capturedDragDropHandler = undefined } -vi.mock('@tauri-apps/api/window', () => ({ - getCurrentWindow: () => ({ - onDragDropEvent: vi.fn((cb: DragDropCallback) => { +vi.mock('@tauri-apps/api/webview', () => ({ + getCurrentWebview: () => ({ + listen: vi.fn((_eventName: string, cb: DragDropCallback) => { capturedDragDropHandler = cb return Promise.resolve(nativeDropUnlisten) }), @@ -105,45 +105,6 @@ describe('uploadImageFile', () => { tauriMode = false }) - - it('uses a MIME-derived filename for unnamed Tauri clipboard images', async () => { - tauriMode = true - - const { invoke, convertFileSrc } = await import('@tauri-apps/api/core') - vi.mocked(invoke).mockResolvedValue('/vault/attachments/123-clipboard-image.webp') - vi.mocked(convertFileSrc).mockReturnValue('asset://localhost/vault/attachments/123-clipboard-image.webp') - - const file = new File([new Uint8Array([0x52, 0x49])], '', { type: 'image/webp' }) - - const url = await uploadImageFile(file, '/vault') - expect(invoke).toHaveBeenCalledWith('save_image', { - vaultPath: '/vault', - filename: 'clipboard-image.webp', - data: expect.any(String), - }) - expect(url).toBe('asset://localhost/vault/attachments/123-clipboard-image.webp') - - tauriMode = false - }) -}) - -describe('clipboardImageFiles', () => { - it('extracts image files when clipboard data also exposes a text data URL', () => { - const file = new File(['webp-data'], 'paste.webp', { type: 'image/webp' }) - const items = [{ - getAsFile: () => file, - kind: 'file', - type: 'image/webp', - }] - const clipboardData = { - files: Object.assign([file], { item: (index: number) => [file][index] }), - getData: (format: string) => format === 'text/plain' ? 'data:image/webp;base64,AAAA' : '', - items: Object.assign(items, { length: items.length }), - types: ['text/plain', 'Files'], - } - - expect(clipboardImageFiles(clipboardData)).toEqual([file]) - }) }) describe('useImageDrop', () => { diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index f28572b7..01bb7fb9 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -1,38 +1,16 @@ import { useEffect, useRef, useState, type RefObject } from 'react' import { invoke, convertFileSrc } from '@tauri-apps/api/core' +import type { Event as TauriEvent, UnlistenFn } from '@tauri-apps/api/event' +import type { DragDropEvent as TauriDragDropPayload } from '@tauri-apps/api/webview' import { isTauri } from '../mock-tauri' -import { useTauriDragDropEvent, type TauriDragDropEvent } from './useTauriDragDropEvent' const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'] -const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { - 'image/bmp': 'bmp', - 'image/gif': 'gif', - 'image/jpeg': 'jpeg', - 'image/png': 'png', - 'image/svg+xml': 'svg', - 'image/tiff': 'tiff', - 'image/webp': 'webp', -} +const TAURI_DRAG_DROP_EVENT = 'tauri://drag-drop' +const TAURI_DRAG_LEAVE_EVENT = 'tauri://drag-leave' type ImageUrlHandler = (url: string) => void -type ClipboardFileItem = { - getAsFile: () => File | null - kind: string - type: string -} -type ClipboardFileItems = { - length: number - [index: number]: ClipboardFileItem | undefined -} -type ClipboardFiles = { - length: number - [index: number]: File | undefined -} -type ClipboardImageData = { - files?: ClipboardFiles - items?: ClipboardFileItems -} | null | undefined +type TauriDropEvent = TauriEvent function hasImageFiles(dt: DataTransfer): boolean { for (let i = 0; i < dt.items.length; i++) { @@ -46,57 +24,6 @@ function isImagePath(path: string): boolean { return IMAGE_EXTENSIONS.includes(ext) } -function fileExtension(filename: string): string | null { - const extension = filename.split('.').pop()?.toLowerCase() - return extension && extension !== filename.toLowerCase() && IMAGE_EXTENSIONS.includes(extension) - ? extension - : null -} - -function imageExtensionForFile(file: File): string | null { - const filenameExtension = fileExtension(file.name) - if (filenameExtension) return filenameExtension - - return IMAGE_EXTENSION_BY_MIME_TYPE[file.type.toLowerCase()] ?? null -} - -function imageAttachmentFilename(file: File): string { - const filename = file.name.trim() - const extension = imageExtensionForFile(file) - if (!filename) return extension ? `clipboard-image.${extension}` : 'clipboard-image' - if (fileExtension(filename)) return filename - return extension ? `${filename}.${extension}` : filename -} - -function uniqueImageFiles(files: File[]): File[] { - return files.filter((file, index) => ( - file.type.startsWith('image/') && files.indexOf(file) === index - )) -} - -function imageFileFromClipboardItem(item: ClipboardFileItem): File | null { - if (item.kind !== 'file' || !item.type.startsWith('image/')) return null - - return item.getAsFile() -} - -function clipboardItemImageFiles(items?: ClipboardFileItems): File[] { - return Array - .from({ length: items?.length ?? 0 }, (_, index) => items?.[index]) - .flatMap(item => item ? [imageFileFromClipboardItem(item)].filter((file): file is File => file !== null) : []) -} - -function clipboardFileListImageFiles(files?: ClipboardFiles): File[] { - return uniqueImageFiles(Array.from({ length: files?.length ?? 0 }, (_, index) => files?.[index]).filter((file): file is File => Boolean(file))) -} - -export function clipboardImageFiles(clipboardData: ClipboardImageData): File[] { - const itemFiles = uniqueImageFiles(clipboardItemImageFiles(clipboardData?.items)) - return itemFiles.length > 0 - ? itemFiles - : clipboardFileListImageFiles(clipboardData?.files) -} - /** 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) { @@ -107,7 +34,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise('save_image', { vaultPath, - filename: imageAttachmentFilename(file), + filename: file.name, data: base64, }) return convertFileSrc(savedPath) @@ -139,6 +66,31 @@ function insertDroppedImages( } } +function cleanupNativeDropListeners(unlisteners: UnlistenFn[]): void { + for (const unlisten of unlisteners) { + void Promise.resolve() + .then(unlisten) + .catch(() => {}) + } +} + +async function registerNativeDropListeners( + handler: (event: TauriDropEvent) => void, +): Promise { + const { getCurrentWebview } = await import('@tauri-apps/api/webview') + const webview = getCurrentWebview() + const unlisteners: UnlistenFn[] = [] + + try { + unlisteners.push(await webview.listen(TAURI_DRAG_DROP_EVENT, handler)) + unlisteners.push(await webview.listen(TAURI_DRAG_LEAVE_EVENT, handler)) + return unlisteners + } catch (error) { + cleanupNativeDropListeners(unlisteners) + throw error + } +} + interface UseImageDropOptions { containerRef: RefObject /** Called with an asset URL for each image dropped via Tauri native drag-drop. */ @@ -146,20 +98,14 @@ interface UseImageDropOptions { vaultPath?: string } -function useLatestImageDropRefs(onImageUrl: ImageUrlHandler | undefined, vaultPath: string | undefined) { +export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) { + const [isDragOver, setIsDragOver] = useState(false) const onImageUrlRef = useRef(onImageUrl) - const vaultPathRef = useRef(vaultPath) - useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl]) + const vaultPathRef = useRef(vaultPath) useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath]) - return { onImageUrlRef, vaultPathRef } -} - -function useHtmlImageDropFeedback( - containerRef: RefObject, - setIsDragOver: (isDragOver: boolean) => void, -) { + // HTML5 DnD visual feedback; BlockNote handles browser-mode uploads. useEffect(() => { const container = containerRef.current if (!container) return @@ -172,10 +118,14 @@ function useHtmlImageDropFeedback( } const handleDragLeave = (e: DragEvent) => { - if (!container.contains(e.relatedTarget as Node)) setIsDragOver(false) + if (!container.contains(e.relatedTarget as Node)) { + setIsDragOver(false) + } } - const handleDrop = () => setIsDragOver(false) + const handleDrop = () => { + setIsDragOver(false) + } container.addEventListener('dragover', handleDragOver) container.addEventListener('dragleave', handleDragLeave) @@ -186,27 +136,42 @@ function useHtmlImageDropFeedback( container.removeEventListener('dragleave', handleDragLeave) container.removeEventListener('drop', handleDrop) } - }, [containerRef, setIsDragOver]) -} + }, [containerRef]) -function handleNativeImageDrop( - event: TauriDragDropEvent, - vaultPath: string | undefined, - onImageUrl: ImageUrlHandler | undefined, -): void { - if (event.payload.type !== 'drop') return - insertDroppedImages(event.payload.paths.filter(isImagePath), vaultPath, onImageUrl) -} + // Tauri native file drop intercepts OS file drops that bypass HTML5 DnD. + useEffect(() => { + if (!isTauri()) return -export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) { - const [isDragOver, setIsDragOver] = useState(false) - const { onImageUrlRef, vaultPathRef } = useLatestImageDropRefs(onImageUrl, vaultPath) + let unlisteners: UnlistenFn[] = [] + let mounted = true - useHtmlImageDropFeedback(containerRef, setIsDragOver) - useTauriDragDropEvent((event) => { - setIsDragOver(false) - handleNativeImageDrop(event, vaultPathRef.current, onImageUrlRef.current) - }) + void (async () => { + try { + const nextUnlisteners = await registerNativeDropListeners((event) => { + if (event.payload.type === 'drop') { + setIsDragOver(false) + insertDroppedImages( + event.payload.paths.filter(isImagePath), + vaultPathRef.current, + onImageUrlRef.current, + ) + return + } + setIsDragOver(false) + }) + if (mounted) unlisteners = nextUnlisteners + else cleanupNativeDropListeners(nextUnlisteners) + } catch { + // Tauri webview API not available. + } + })() + + return () => { + mounted = false + cleanupNativeDropListeners(unlisteners) + unlisteners = [] + } + }, []) return { isDragOver } } diff --git a/src/utils/tauriDragDropConfig.test.ts b/src/utils/tauriDragDropConfig.test.ts index e7c620c7..e8487a09 100644 --- a/src/utils/tauriDragDropConfig.test.ts +++ b/src/utils/tauriDragDropConfig.test.ts @@ -1,9 +1,9 @@ import { readFileSync } from 'node:fs' describe('Tauri drag/drop configuration', () => { - it('keeps native file drops enabled for path-aware app inputs', () => { + it('keeps browser file drops disabled so images use the native attachment path', () => { const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8')) - expect(config.app.windows[0].dragDropEnabled).toBe(true) + expect(config.app.windows[0].dragDropEnabled).toBe(false) }) }) diff --git a/tests/smoke/clipboard-image-paste.spec.ts b/tests/smoke/clipboard-image-paste.spec.ts deleted file mode 100644 index 2918b331..00000000 --- a/tests/smoke/clipboard-image-paste.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import fs from 'fs' -import path from 'path' -import { test, expect, type Page } from '@playwright/test' -import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault' -import { triggerMenuCommand } from './testBridge' - -let tempVaultDir: string - -function activeNotePath(): string { - return path.join(tempVaultDir, 'project', 'alpha-project.md') -} - -async function dispatchClipboardImagePaste(page: Page): Promise { - await page.evaluate(() => { - const target = document.querySelector( - '.bn-editor [data-content-type="paragraph"] .bn-inline-content', - ) - if (!target) throw new Error('Editor paste target was not found') - - const file = new File( - [new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00])], - 'reported.webp', - { type: 'image/webp' }, - ) - const items = [{ - getAsFile: () => file, - kind: 'file', - type: 'image/webp', - }] - const files = [file] - const clipboardData = { - files: Object.assign(files, { item: (index: number) => files[index] }), - getData: (format: string) => format === 'text/plain' ? 'data:image/webp;base64,UklGRgAAAAA=' : '', - items: Object.assign(items, { length: items.length }), - types: ['text/plain', 'Files'], - } - - const event = new Event('paste', { bubbles: true, cancelable: true }) - Object.defineProperty(event, 'clipboardData', { value: clipboardData }) - - if (target.dispatchEvent(event)) { - throw new Error('Clipboard image paste was not intercepted') - } - }) -} - -test.beforeEach(async ({ page }, testInfo) => { - testInfo.setTimeout(60_000) - tempVaultDir = createFixtureVaultCopy() - await openFixtureVaultTauri(page, tempVaultDir) -}) - -test.afterEach(() => { - removeFixtureVaultCopy(tempVaultDir) -}) - -test('clipboard WebP paste uses the attachment image flow instead of raw text', async ({ page }) => { - await page.getByText('Alpha Project', { exact: true }).first().click() - await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 }) - await page.getByRole('textbox').last().click() - await page.evaluate(() => { - type MockHandlersWindow = Window & typeof globalThis & { - __mockHandlers?: Record) => unknown> - __TAURI_INTERNALS__?: { - convertFileSrc?: (filePath: string, protocol?: string) => string - } - __pasteSaveImageCalls?: number - } - const mockWindow = window as MockHandlersWindow - const mockHandlers = mockWindow.__mockHandlers - if (!mockHandlers) throw new Error('Mock handlers were not installed') - if (!mockWindow.__TAURI_INTERNALS__) throw new Error('Tauri internals were not installed') - mockWindow.__TAURI_INTERNALS__.convertFileSrc = (filePath, protocol = 'asset') => ( - `${protocol}://localhost/${encodeURIComponent(filePath)}` - ) - mockHandlers.save_image = (args) => { - mockWindow.__pasteSaveImageCalls = (mockWindow.__pasteSaveImageCalls ?? 0) + 1 - const vaultPath = String(args?.vaultPath ?? args?.vault_path ?? '') - const filename = String(args?.filename ?? 'clipboard-image.webp') - return `${vaultPath}/attachments/123-${filename}` - } - }) - - await dispatchClipboardImagePaste(page) - await expect.poll(() => page.evaluate(() => { - type PasteWindow = Window & typeof globalThis & { __pasteSaveImageCalls?: number } - return (window as PasteWindow).__pasteSaveImageCalls ?? 0 - }), { timeout: 5_000 }).toBe(1) - await expect(page.locator('.bn-editor img')).toHaveCount(1, { timeout: 5_000 }) - - await triggerMenuCommand(page, 'file-save') - await expect.poll(() => fs.readFileSync(activeNotePath(), 'utf8'), { - timeout: 10_000, - }).toMatch(/!\[[^\]]*\]\(attachments\/[^)\s]*reported\.webp\)/) - - const savedMarkdown = fs.readFileSync(activeNotePath(), 'utf8') - expect(savedMarkdown).not.toContain('data:image/webp') -})