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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-02-21 10:07:02 +01:00
parent d94236c482
commit db1da4430e
7 changed files with 146 additions and 2 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -1792,6 +1792,7 @@ dependencies = [
name = "laputa"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"gray_matter",
"log",

View File

@@ -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"

View File

@@ -51,6 +51,11 @@ fn git_push(vault_path: String) -> Result<String, String> {
git::git_push(&vault_path)
}
#[tauri::command]
fn save_image(vault_path: String, filename: String, data: String) -> Result<String, String> {
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");

View File

@@ -658,6 +658,47 @@ pub fn delete_frontmatter_property(path: &str, key: &str) -> Result<String, Stri
with_frontmatter(path, |content| update_frontmatter_content(content, key, None))
}
/// Check if a character is safe for use in filenames (alphanumeric, dot, dash, underscore).
fn is_safe_filename_char(c: char) -> 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<String, String> {
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"));
}
}

View File

@@ -138,6 +138,7 @@ function App() {
onAddProperty={notes.handleAddProperty}
showAIChat={showAIChat}
onToggleAIChat={() => setShowAIChat(c => !c)}
vaultPath={vaultPath}
/>
</div>
</div>

View File

@@ -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<void>
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<string | null>(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<string>((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<Map<string, any[]>>(new Map())
const prevActivePathRef = useRef<string | null>(null)

View File

@@ -1109,6 +1109,10 @@ const mockHandlers: Record<string, (args: any) => 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 {