From db1da4430e62e62eb40f43f6215dc0f2e4bb9e69 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 10:07:02 +0100 Subject: [PATCH 1/4] fix: wire up BlockNote uploadFile to enable image uploads in editor The editor's useCreateBlockNote was missing the uploadFile callback, so BlockNote had no handler for image uploads (drag-drop, paste, or toolbar button). Images now convert to data URLs for immediate display. In Tauri mode, uploaded images are also persisted to the vault's attachments/ directory via a new save_image backend command. Co-Authored-By: Claude Opus 4.6 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 8 +++- src-tauri/src/vault.rs | 99 +++++++++++++++++++++++++++++++++++++++ src/App.tsx | 1 + src/components/Editor.tsx | 34 +++++++++++++- src/mock-tauri.ts | 4 ++ 7 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1ac95db1..56524a17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1792,6 +1792,7 @@ dependencies = [ name = "laputa" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", "gray_matter", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 80b38d47..7b13105b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,6 +24,7 @@ tauri-plugin-log = "2" gray_matter = "0.2" walkdir = "2" chrono = { version = "0.4", features = ["serde"] } +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9aba027f..50300f81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -51,6 +51,11 @@ fn git_push(vault_path: String) -> Result { git::git_push(&vault_path) } +#[tauri::command] +fn save_image(vault_path: String, filename: String, data: String) -> Result { + vault::save_image(&vault_path, &filename, &data) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -73,7 +78,8 @@ pub fn run() { get_modified_files, get_file_diff, git_commit, - git_push + git_push, + save_image ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index ee37b3f8..14f6e479 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -658,6 +658,47 @@ pub fn delete_frontmatter_property(path: &str, key: &str) -> Result bool { + c.is_alphanumeric() || matches!(c, '.' | '-' | '_') +} + +/// Sanitize a filename by replacing unsafe characters with underscores. +fn sanitize_filename(name: &str) -> String { + name.chars() + .map(|c| if is_safe_filename_char(c) { c } else { '_' }) + .collect() +} + +/// Save an uploaded image to the vault's attachments directory. +/// Returns the absolute path to the saved file. +pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result { + use base64::Engine; + + let vault = Path::new(vault_path); + let attachments_dir = vault.join("attachments"); + + fs::create_dir_all(&attachments_dir) + .map_err(|e| format!("Failed to create attachments directory: {}", e))?; + + // Generate unique filename to avoid collisions + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename)); + let target_path = attachments_dir.join(&unique_name); + + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| format!("Invalid base64 data: {}", e))?; + + fs::write(&target_path, bytes) + .map_err(|e| format!("Failed to write image: {}", e))?; + + Ok(target_path.to_string_lossy().to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -1471,4 +1512,62 @@ References: } // Frontmatter update/delete tests are in frontmatter.rs + + // --- save_image tests --- + + #[test] + fn test_sanitize_filename_safe_chars() { + assert_eq!(sanitize_filename("photo.png"), "photo.png"); + assert_eq!(sanitize_filename("my-image_01.jpg"), "my-image_01.jpg"); + } + + #[test] + fn test_sanitize_filename_unsafe_chars() { + assert_eq!(sanitize_filename("my file (1).png"), "my_file__1_.png"); + assert_eq!(sanitize_filename("path/to/img.png"), "path_to_img.png"); + } + + #[test] + fn test_save_image_creates_file() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(b"fake image data"); + + let result = save_image(vault_path, "test.png", &data); + assert!(result.is_ok()); + + let saved_path = result.unwrap(); + assert!(std::path::Path::new(&saved_path).exists()); + assert!(saved_path.contains("attachments")); + assert!(saved_path.contains("test.png")); + + let content = fs::read(&saved_path).unwrap(); + assert_eq!(content, b"fake image data"); + } + + #[test] + fn test_save_image_creates_attachments_dir() { + use base64::Engine; + + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + let attachments = dir.path().join("attachments"); + assert!(!attachments.exists()); + + let data = base64::engine::general_purpose::STANDARD.encode(b"test"); + save_image(vault_path, "img.png", &data).unwrap(); + assert!(attachments.exists()); + } + + #[test] + fn test_save_image_invalid_base64() { + let dir = TempDir::new().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + let result = save_image(vault_path, "test.png", "not-valid-base64!!!"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid base64")); + } } diff --git a/src/App.tsx b/src/App.tsx index d716a3b7..d3522176 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -138,6 +138,7 @@ function App() { onAddProperty={notes.handleAddProperty} showAIChat={showAIChat} onToggleAIChat={() => setShowAIChat(c => !c)} + vaultPath={vaultPath} /> diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 850caec5..cfd539c5 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -4,6 +4,8 @@ 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 } from '@tauri-apps/api/core' +import { isTauri } from '../mock-tauri' import type { VaultEntry, GitCommit } from '../types' import { Inspector, type FrontmatterValue } from './Inspector' import { AIChatPanel } from './AIChatPanel' @@ -45,6 +47,7 @@ interface EditorProps { onAddProperty?: (path: string, key: string, value: FrontmatterValue) => Promise showAIChat?: boolean onToggleAIChat?: () => void + vaultPath?: string } // --- Custom Inline Content: WikiLink --- @@ -147,13 +150,42 @@ export const Editor = memo(function Editor({ inspectorEntry, inspectorContent, allContent, gitHistory, onUpdateFrontmatter, onDeleteProperty, onAddProperty, showAIChat, onToggleAIChat, + vaultPath, }: EditorProps) { const [diffMode, setDiffMode] = useState(false) const [diffContent, setDiffContent] = useState(null) const [diffLoading, setDiffLoading] = useState(false) + // Ref for vaultPath so the uploadFile closure always sees the latest value + const vaultPathRef = useRef(vaultPath) + vaultPathRef.current = vaultPath + // Single editor instance — reused across all tabs - const editor = useCreateBlockNote({ schema }) + const editor = useCreateBlockNote({ + schema, + uploadFile: async (file: File) => { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error) + reader.readAsDataURL(file) + }) + + // In Tauri mode, also persist the file to the vault's attachments directory + if (isTauri() && vaultPathRef.current) { + const base64 = dataUrl.split(',')[1] + if (base64) { + invoke('save_image', { + vaultPath: vaultPathRef.current, + filename: file.name, + data: base64, + }).catch(err => console.warn('Failed to save image to vault:', err)) + } + } + + return dataUrl + }, + }) // Cache parsed blocks per tab path for instant switching const tabCacheRef = useRef>(new Map()) const prevActivePathRef = useRef(null) diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 670f3122..9a33f3ae 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1109,6 +1109,10 @@ const mockHandlers: Record any> = { git_push: () => { return 'Everything up-to-date' }, + save_image: (args: { filename: string; data: string }) => { + // In mock mode, return a data URL so the image displays in the editor + return `data:image/png;base64,${args.data}` + }, } export function isTauri(): boolean { From 5e738f1e2b7e32bcecda36b473e89e7aa0dd80b6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 10:15:58 +0100 Subject: [PATCH 2/4] test: add E2E tests for image upload in editor Verifies that the slash menu Image option works and that no upload-related errors occur when inserting an image block. Co-Authored-By: Claude Opus 4.6 --- e2e/image-upload.spec.ts | 125 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 e2e/image-upload.spec.ts diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts new file mode 100644 index 00000000..3f7c7d4a --- /dev/null +++ b/e2e/image-upload.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '@playwright/test' +import * as path from 'path' +import * as fs from 'fs' + +// Minimal valid PNG: 1x1 red pixel +const TEST_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' + +function createTestPng(filepath: string) { + fs.mkdirSync(path.dirname(filepath), { recursive: true }) + fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) +} + +test('image upload via slash menu inserts image block', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Click first note item (each note card has a type-icon data-testid) + const noteItem = page.locator('[data-testid="type-icon"]').first() + await noteItem.click({ timeout: 10000 }) + await page.waitForTimeout(500) + + // Screenshot before image upload + await page.screenshot({ path: 'test-results/image-upload-before.png', fullPage: true }) + + // Click into the editor to focus it + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + await editor.click() + await page.waitForTimeout(200) + + // Press Enter to create a new line, then type /image to open slash menu + await page.keyboard.press('Enter') + await page.waitForTimeout(100) + await page.keyboard.type('/image', { delay: 50 }) + await page.waitForTimeout(500) + + // Screenshot showing slash menu with image option + await page.screenshot({ path: 'test-results/image-slash-menu.png', fullPage: true }) + + // Look for Image item in the slash menu + const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first() + const menuVisible = await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false) + + if (menuVisible) { + await imageMenuItem.click() + await page.waitForTimeout(500) + + // After inserting image block, look for upload tab/button + const uploadTab = page.getByText(/upload/i).first() + const uploadVisible = await uploadTab.isVisible({ timeout: 3000 }).catch(() => false) + + if (uploadVisible) { + await uploadTab.click() + await page.waitForTimeout(200) + + // Create a test image file + const testImagePath = path.join(__dirname, '..', 'test-results', 'test-image.png') + createTestPng(testImagePath) + + // Try to upload via input[type=file] or file chooser + const uploadInput = page.locator('input[type="file"]').first() + if (await uploadInput.count() > 0) { + await uploadInput.setInputFiles(testImagePath) + } else { + const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 5000 }) + const uploadBtn = page.locator('button', { hasText: /upload|choose|browse/i }).first() + if (await uploadBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await uploadBtn.click() + const fileChooser = await fileChooserPromise + await fileChooser.setFiles(testImagePath) + } + } + + await page.waitForTimeout(1000) + + // Verify an image is now displayed in the editor + const imageInEditor = page.locator('.bn-editor img') + const imageCount = await imageInEditor.count() + console.log(`Images found in editor after upload: ${imageCount}`) + + // Clean up test image + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) + } + } + + // Screenshot after image upload attempt + await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true }) +}) + +test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(1000) + + // Click first note + const noteItem = page.locator('[data-testid="type-icon"]').first() + await noteItem.click({ timeout: 10000 }) + await page.waitForTimeout(500) + + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 10000 }) + + // Capture console errors + const errors: string[] = [] + page.on('pageerror', (err) => errors.push(err.message)) + + // Insert an image block via slash command + await editor.click() + await page.keyboard.press('Enter') + await page.keyboard.type('/image', { delay: 30 }) + await page.waitForTimeout(500) + + // Click Image in the slash menu if visible + const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first() + if (await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false)) { + await imageMenuItem.click() + await page.waitForTimeout(500) + } + + await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true }) + + // No errors related to upload should have occurred + const uploadErrors = errors.filter(e => e.includes('upload')) + expect(uploadErrors).toHaveLength(0) +}) From e02db50533450c1793fce7590931ce8958b80ee2 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 11:00:41 +0100 Subject: [PATCH 3/4] fix: use blob URLs for image uploads instead of data URLs The uploadFile callback was converting images to data URLs via FileReader, which caused images to get stuck on "Loading..." in the editor. Switched to URL.createObjectURL() which returns a lightweight blob URL immediately. The Tauri vault-save path is now fully fire-and-forget so it never blocks the URL return. Co-Authored-By: Claude Opus 4.6 --- e2e/image-upload.spec.ts | 93 ++++++++++++++------------------------- src/components/Editor.tsx | 34 +++++++------- 2 files changed, 50 insertions(+), 77 deletions(-) diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts index 3f7c7d4a..43a596e5 100644 --- a/e2e/image-upload.spec.ts +++ b/e2e/image-upload.spec.ts @@ -11,81 +11,56 @@ function createTestPng(filepath: string) { fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) } -test('image upload via slash menu inserts image block', async ({ page }) => { +test('image upload via file picker displays image with blob URL', async ({ page }) => { await page.goto('/') await page.waitForTimeout(1000) - // Click first note item (each note card has a type-icon data-testid) - const noteItem = page.locator('[data-testid="type-icon"]').first() - await noteItem.click({ timeout: 10000 }) + // Open a note + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) await page.waitForTimeout(500) - // Screenshot before image upload - await page.screenshot({ path: 'test-results/image-upload-before.png', fullPage: true }) - - // Click into the editor to focus it const editor = page.locator('.bn-editor') await expect(editor).toBeVisible({ timeout: 10000 }) await editor.click() await page.waitForTimeout(200) - // Press Enter to create a new line, then type /image to open slash menu + // Insert image block via slash command await page.keyboard.press('Enter') await page.waitForTimeout(100) - await page.keyboard.type('/image', { delay: 50 }) + await page.keyboard.type('/image', { delay: 80 }) await page.waitForTimeout(500) - // Screenshot showing slash menu with image option - await page.screenshot({ path: 'test-results/image-slash-menu.png', fullPage: true }) + // Select Image from slash menu (press Enter to pick first match) + await page.keyboard.press('Enter') + await page.waitForTimeout(500) - // Look for Image item in the slash menu - const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first() - const menuVisible = await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false) + // Verify Upload tab is available (uploadFile is configured) + const fileInput = page.locator('input[type="file"]') + expect(await fileInput.count()).toBeGreaterThan(0) - if (menuVisible) { - await imageMenuItem.click() - await page.waitForTimeout(500) + // Upload a test image + const testImagePath = path.join(process.cwd(), 'test-results', 'test-image.png') + createTestPng(testImagePath) - // After inserting image block, look for upload tab/button - const uploadTab = page.getByText(/upload/i).first() - const uploadVisible = await uploadTab.isVisible({ timeout: 3000 }).catch(() => false) + await fileInput.first().setInputFiles(testImagePath) + await page.waitForTimeout(2000) - if (uploadVisible) { - await uploadTab.click() - await page.waitForTimeout(200) + // Verify: image element exists in the editor + const images = page.locator('.bn-editor img') + const imageCount = await images.count() + expect(imageCount).toBeGreaterThan(0) - // Create a test image file - const testImagePath = path.join(__dirname, '..', 'test-results', 'test-image.png') - createTestPng(testImagePath) + // Verify: image uses blob URL (not stuck on empty or data URL) + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^blob:/) - // Try to upload via input[type=file] or file chooser - const uploadInput = page.locator('input[type="file"]').first() - if (await uploadInput.count() > 0) { - await uploadInput.setInputFiles(testImagePath) - } else { - const fileChooserPromise = page.waitForEvent('filechooser', { timeout: 5000 }) - const uploadBtn = page.locator('button', { hasText: /upload|choose|browse/i }).first() - if (await uploadBtn.isVisible({ timeout: 1000 }).catch(() => false)) { - await uploadBtn.click() - const fileChooser = await fileChooserPromise - await fileChooser.setFiles(testImagePath) - } - } + // Verify: no "Loading..." elements remain + const loadingEls = page.locator('.bn-file-loading-preview') + expect(await loadingEls.count()).toBe(0) - await page.waitForTimeout(1000) - - // Verify an image is now displayed in the editor - const imageInEditor = page.locator('.bn-editor img') - const imageCount = await imageInEditor.count() - console.log(`Images found in editor after upload: ${imageCount}`) - - // Clean up test image - if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) - } - } - - // Screenshot after image upload attempt await page.screenshot({ path: 'test-results/image-upload-after.png', fullPage: true }) + + if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) }) test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => { @@ -93,8 +68,7 @@ test('editor has uploadFile configured (no error on image block insert)', async await page.waitForTimeout(1000) // Click first note - const noteItem = page.locator('[data-testid="type-icon"]').first() - await noteItem.click({ timeout: 10000 }) + await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) await page.waitForTimeout(500) const editor = page.locator('.bn-editor') @@ -110,12 +84,9 @@ test('editor has uploadFile configured (no error on image block insert)', async await page.keyboard.type('/image', { delay: 30 }) await page.waitForTimeout(500) - // Click Image in the slash menu if visible - const imageMenuItem = page.locator('[class*="suggestionMenu"] [class*="item"]', { hasText: 'Image' }).first() - if (await imageMenuItem.isVisible({ timeout: 3000 }).catch(() => false)) { - await imageMenuItem.click() - await page.waitForTimeout(500) - } + // Press Enter to select Image + await page.keyboard.press('Enter') + await page.waitForTimeout(500) await page.screenshot({ path: 'test-results/image-block-inserted.png', fullPage: true }) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index cfd539c5..54567786 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -164,26 +164,28 @@ export const Editor = memo(function Editor({ const editor = useCreateBlockNote({ schema, uploadFile: async (file: File) => { - const dataUrl = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = () => reject(reader.error) - reader.readAsDataURL(file) - }) + // Use blob URL for immediate display — lightweight and avoids encoding large files as data URLs + const blobUrl = URL.createObjectURL(file) - // In Tauri mode, also persist the file to the vault's attachments directory + // In Tauri mode, persist the file to the vault's attachments directory (fire-and-forget) if (isTauri() && vaultPathRef.current) { - const base64 = dataUrl.split(',')[1] - if (base64) { - invoke('save_image', { - vaultPath: vaultPathRef.current, - filename: file.name, - data: base64, - }).catch(err => console.warn('Failed to save image to vault:', err)) - } + const vaultPath = vaultPathRef.current + file.arrayBuffer().then(buf => { + const bytes = new Uint8Array(buf) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + const base64 = btoa(binary) + if (base64) { + invoke('save_image', { + vaultPath, + filename: file.name, + data: base64, + }).catch(err => console.warn('Failed to save image to vault:', err)) + } + }).catch(err => console.warn('Failed to read image for vault save:', err)) } - return dataUrl + return blobUrl }, }) // Cache parsed blocks per tab path for instant switching From deea70a0987e59284b94abc6968801fd16b11455 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 13:06:04 +0100 Subject: [PATCH 4/4] fix: use stable asset URLs for image uploads instead of ephemeral blob URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blob URLs are session-scoped and don't survive page reloads — images would break on Cmd+Shift+R. Now uploadFile awaits save_image (which persists to vault/attachments/) and returns a convertFileSrc asset URL that the Tauri webview can resolve indefinitely. - Editor.tsx: await save_image → convertFileSrc for Tauri mode; FileReader.readAsDataURL fallback for browser dev mode - tauri.conf.json: enable assetProtocol with scope ["**"] - mock-tauri.ts: save_image returns a plausible file path - E2E test: expect data: URLs in browser dev mode Co-Authored-By: Claude Opus 4.6 --- e2e/image-upload.spec.ts | 6 +++--- src-tauri/tauri.conf.json | 6 +++++- src/components/Editor.tsx | 41 +++++++++++++++++++-------------------- src/mock-tauri.ts | 8 +++++--- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts index 43a596e5..0781a729 100644 --- a/e2e/image-upload.spec.ts +++ b/e2e/image-upload.spec.ts @@ -11,7 +11,7 @@ function createTestPng(filepath: string) { fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) } -test('image upload via file picker displays image with blob URL', async ({ page }) => { +test('image upload via file picker displays image with data URL', async ({ page }) => { await page.goto('/') await page.waitForTimeout(1000) @@ -50,9 +50,9 @@ test('image upload via file picker displays image with blob URL', async ({ page const imageCount = await images.count() expect(imageCount).toBeGreaterThan(0) - // Verify: image uses blob URL (not stuck on empty or data URL) + // Verify: image uses data URL (stable, survives reload in dev mode) const src = await images.first().getAttribute('src') - expect(src).toMatch(/^blob:/) + expect(src).toMatch(/^data:/) // Verify: no "Loading..." elements remain const loadingEls = page.locator('.bn-file-loading-preview') diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index a332c728..a557e316 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["**"] + } } }, "bundle": { diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 54567786..b02beac4 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -4,7 +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 } from '@tauri-apps/api/core' +import { invoke, convertFileSrc } from '@tauri-apps/api/core' import { isTauri } from '../mock-tauri' import type { VaultEntry, GitCommit } from '../types' import { Inspector, type FrontmatterValue } from './Inspector' @@ -164,28 +164,27 @@ export const Editor = memo(function Editor({ const editor = useCreateBlockNote({ schema, uploadFile: async (file: File) => { - // Use blob URL for immediate display — lightweight and avoids encoding large files as data URLs - const blobUrl = URL.createObjectURL(file) - - // In Tauri mode, persist the file to the vault's attachments directory (fire-and-forget) if (isTauri() && vaultPathRef.current) { - const vaultPath = vaultPathRef.current - file.arrayBuffer().then(buf => { - const bytes = new Uint8Array(buf) - let binary = '' - for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) - const base64 = btoa(binary) - if (base64) { - invoke('save_image', { - vaultPath, - filename: file.name, - data: base64, - }).catch(err => console.warn('Failed to save image to vault:', err)) - } - }).catch(err => console.warn('Failed to read image for vault save:', err)) + // 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) } - - return blobUrl + // 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) + }) }, }) // Cache parsed blocks per tab path for instant switching diff --git a/src/mock-tauri.ts b/src/mock-tauri.ts index 9a33f3ae..e74e69a3 100644 --- a/src/mock-tauri.ts +++ b/src/mock-tauri.ts @@ -1109,9 +1109,11 @@ const mockHandlers: Record any> = { git_push: () => { return 'Everything up-to-date' }, - save_image: (args: { filename: string; data: string }) => { - // In mock mode, return a data URL so the image displays in the editor - return `data:image/png;base64,${args.data}` + save_image: (args: { vault_path?: string; filename: string; data: string }) => { + // Return a plausible file path matching the real Rust backend behavior + const vault = args.vault_path ?? '/Users/luca/Laputa' + const timestamp = Date.now() + return `${vault}/attachments/${timestamp}-${args.filename}` }, }