From db1da4430e62e62eb40f43f6215dc0f2e4bb9e69 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 21 Feb 2026 10:07:02 +0100 Subject: [PATCH] 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 {