fix: restore native image attachments
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"hiddenTitle": true,
|
||||
"backgroundColor": "#F7F6F3",
|
||||
"dragDropEnabled": true
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
Reference in New Issue
Block a user