fix: restore native image attachments

This commit is contained in:
lucaronin
2026-05-06 14:47:43 +02:00
parent 41ee40d427
commit 2e7c5cb433
6 changed files with 82 additions and 293 deletions

View File

@@ -27,7 +27,7 @@
},
"hiddenTitle": true,
"backgroundColor": "#F7F6F3",
"dragDropEnabled": true
"dragDropEnabled": false
}
],
"security": {

View File

@@ -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<typeof useCreateBlockNote>,
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<typeof useCreateBlockNote>,
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(),

View File

@@ -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', () => {

View File

@@ -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<string, string> = {
'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<TauriDragDropPayload>
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<string> {
if (isTauri() && vaultPath) {
@@ -107,7 +34,7 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
const base64 = btoa(binary)
const savedPath = await invoke<string>('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<UnlistenFn[]> {
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
const webview = getCurrentWebview()
const unlisteners: UnlistenFn[] = []
try {
unlisteners.push(await webview.listen<TauriDragDropPayload>(TAURI_DRAG_DROP_EVENT, handler))
unlisteners.push(await webview.listen<TauriDragDropPayload>(TAURI_DRAG_LEAVE_EVENT, handler))
return unlisteners
} catch (error) {
cleanupNativeDropListeners(unlisteners)
throw error
}
}
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** 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<HTMLDivElement | null>,
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 }
}

View File

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

View File

@@ -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<void> {
await page.evaluate(() => {
const target = document.querySelector<HTMLElement>(
'.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<string, (args?: Record<string, unknown>) => 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')
})