diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b06daaa..fa8b75a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -38,9 +38,8 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "str futures = "0.3" rayon = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } -arboard = { version = "3", features = ["image-data", "wayland-data-control"] } -png = "0.17" - [target.'cfg(not(target_os = "android"))'.dependencies] tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" +arboard = { version = "3", features = ["image-data", "wayland-data-control"] } +png = "0.17" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 71bd6ab..3ee5b83 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e6e95a7..cc012b2 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 6c9b7fa..bbadabe 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png new file mode 100644 index 0000000..edbf6d9 Binary files /dev/null and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 81696a1..6bf8b63 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index 76742b5..1e5d9ac 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 2914ba5..cd13ce4 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index 440a267..3e6e00c 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 254804e..803d336 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 3adf730..5e32c73 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index 85b3f84..2f663ef 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 626ec15..e8ef717 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index fd7b80e..e33bed5 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index f040ada..42e49c0 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 9d1f081..433542f 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 09eaf9e..0c4b7c6 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 683f3c6..1b8dc07 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png new file mode 100644 index 0000000..123be3b Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png new file mode 100644 index 0000000..126f80f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png new file mode 100644 index 0000000..126f80f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png new file mode 100644 index 0000000..c361b32 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png new file mode 100644 index 0000000..ba54d99 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png new file mode 100644 index 0000000..f495b32 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png new file mode 100644 index 0000000..f495b32 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png new file mode 100644 index 0000000..c83b482 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png new file mode 100644 index 0000000..126f80f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png new file mode 100644 index 0000000..22bea7f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png new file mode 100644 index 0000000..22bea7f Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png new file mode 100644 index 0000000..10db42c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png new file mode 100644 index 0000000..386c58d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png new file mode 100644 index 0000000..10db42c Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png new file mode 100644 index 0000000..a5ca685 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png new file mode 100644 index 0000000..0307f73 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png new file mode 100644 index 0000000..8e26822 Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png new file mode 100644 index 0000000..3cf739d Binary files /dev/null and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 7861bac..c2b0cef 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -11,9 +11,21 @@ use tauri::{AppHandle, Manager, State}; pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { operations::ensure_vault_structure(&path)?; - // Initialize search index - let search = SearchIndex::new(&path)?; - search.rebuild(&path)?; + // Initialize search index — rebuild in background on Android (FUSE is slow) + let search = std::sync::Arc::new(SearchIndex::new(&path)?); + #[cfg(target_os = "android")] + { + let search_bg = search.clone(); + let vault = path.clone(); + std::thread::spawn(move || { + let _ = search_bg.rebuild(&vault); + log::info!("Android: search index rebuild complete"); + }); + } + #[cfg(not(target_os = "android"))] + { + search.rebuild(&path)?; + } *state.search_index.lock().map_err(|e| e.to_string())? = Some(search); // Start file watcher @@ -466,6 +478,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) -> /// Read image from system clipboard (bypasses WebKitGTK clipboard bug). /// Returns PNG bytes as Vec, or error if no image on clipboard. +#[cfg(not(target_os = "android"))] #[tauri::command] pub fn read_clipboard_image() -> Result, String> { let mut clipboard = @@ -490,6 +503,12 @@ pub fn read_clipboard_image() -> Result, String> { Ok(buf) } +#[cfg(target_os = "android")] +#[tauri::command] +pub fn read_clipboard_image() -> Result, String> { + Err("Clipboard image reading not supported on Android".to_string()) +} + // ── Attachments ── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5246ca1..6a7cbfc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,7 +44,7 @@ pub fn run() { )?; } - // On Android, set config dir from Tauri's path resolver + // On Android, set config dir from Tauri's path resolver, then reload config #[cfg(target_os = "android")] { if let Ok(config_dir) = app.path().config_dir() { @@ -52,6 +52,11 @@ pub fn run() { } else if let Ok(data_dir) = app.path().data_dir() { commands::set_android_config_dir(data_dir); } + // Reload config now that the Android config dir is available + let reloaded = commands::load_app_config(); + let _ = app.state::().config.lock().map(|mut cfg| { + *cfg = reloaded; + }); } #[cfg(not(target_os = "android"))] diff --git a/src-tauri/src/search/mod.rs b/src-tauri/src/search/mod.rs index 4bfe50f..8a58f27 100644 --- a/src-tauri/src/search/mod.rs +++ b/src-tauri/src/search/mod.rs @@ -77,18 +77,20 @@ impl SearchIndex { for entry in WalkDir::new(vault_path) .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| { + .filter_entry(|e| { let p = e.path(); - p.is_file() - && p.extension().and_then(|x| x.to_str()) == Some("md") - && !p.starts_with(&hn_dir) + !p.starts_with(&hn_dir) && !p .file_name() .and_then(|n| n.to_str()) .map(|n| n.starts_with('.')) .unwrap_or(false) }) + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().is_file() + && e.path().extension().and_then(|x| x.to_str()) == Some("md") + }) { if let Ok(raw) = fs::read_to_string(entry.path()) { let filename = entry.file_name().to_string_lossy().to_string(); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index c949b94..a58c798 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -3,10 +3,11 @@ use crate::types::AppConfig; use notify::RecommendedWatcher; use std::sync::atomic::AtomicBool; use std::sync::Mutex; +use std::sync::Arc; pub struct AppState { pub config: Mutex, - pub search_index: Mutex>, + pub search_index: Mutex>>, pub watcher: Mutex>, pub importing: AtomicBool, pub pending_open_file: Mutex>, diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index a42ad27..37fa055 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -59,11 +59,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec { return entries; }; + // Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count) let mut dirs: Vec<_> = read_dir .filter_map(|e| e.ok()) .filter(|e| { - let path = e.path(); - path.is_dir() && !is_hidden(&path) + e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path()) }) .collect(); @@ -74,6 +74,8 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec { .cmp(&b.file_name().to_string_lossy().to_lowercase()) }); + // Store note_count for the parent directory (used when this is the top-level call) + // Each directory pushes its own entry with its own note_count for dir_entry in dirs { let path = dir_entry.path(); let name = dir_entry.file_name().to_string_lossy().to_string(); @@ -83,32 +85,71 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec { .to_string_lossy() .to_string(); - let children = scan_dir_recursive(&path, vault_root); - let note_count = count_notes_in_dir(&path); + let (children, child_note_count) = scan_dir_with_count(&path, vault_root); entries.push(NotebookEntry { name, path: path.to_string_lossy().to_string(), relative_path: relative, children, - note_count, + note_count: child_note_count, }); } entries } -fn count_notes_in_dir(dir: &Path) -> usize { - fs::read_dir(dir) - .map(|rd| { - rd.filter_map(|e| e.ok()) - .filter(|e| { - let path = e.path(); - path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md") - }) - .count() - }) - .unwrap_or(0) +/// Combined scan: returns (children, note_count) in a single read_dir pass. +fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec, usize) { + let root = Path::new(vault_root); + + let Ok(read_dir) = fs::read_dir(dir) else { + return (Vec::new(), 0); + }; + + let mut subdirs = Vec::new(); + let mut note_count = 0usize; + for entry in read_dir.filter_map(|e| e.ok()) { + let ft = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if ft.is_dir() && !is_hidden(&entry.path()) { + subdirs.push(entry); + } else if ft.is_file() && entry.path().extension().and_then(|x| x.to_str()) == Some("md") { + note_count += 1; + } + } + + subdirs.sort_by(|a, b| { + a.file_name() + .to_string_lossy() + .to_lowercase() + .cmp(&b.file_name().to_string_lossy().to_lowercase()) + }); + + let mut entries = Vec::new(); + for dir_entry in subdirs { + let path = dir_entry.path(); + let name = dir_entry.file_name().to_string_lossy().to_string(); + let relative = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + let (children, child_note_count) = scan_dir_with_count(&path, vault_root); + + entries.push(NotebookEntry { + name, + path: path.to_string_lossy().to_string(), + relative_path: relative, + children, + note_count: child_note_count, + }); + } + + (entries, note_count) } fn is_hidden(path: &Path) -> bool { @@ -129,37 +170,88 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result = if notebook_path.is_some() { - // Only direct children for a specific notebook - fs::read_dir(root) - .map_err(|e| e.to_string())? - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) - .collect() - } else { - // Recursive for "All Notes" - WalkDir::new(root) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path))) - .map(|e| e.path().to_path_buf()) - .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) - .collect() - }; + // On Android, use metadata-only scan (no file reads) for fast listing on FUSE + #[cfg(target_os = "android")] + { + let mut notes = Vec::new(); + // Log what read_dir sees + if let Ok(rd) = fs::read_dir(root) { + let items: Vec<_> = rd.filter_map(|e| e.ok()).collect(); + log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root); + for (i, entry) in items.iter().enumerate().take(5) { + let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err"); + log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft); + } + } else { + log::info!("scan_notes: read_dir FAILED for {:?}", root); + } - // Process files in parallel with partial reads - let mut notes: Vec = md_files - .par_iter() - .filter_map(|path| read_note_entry_fast(path, vault_root).ok()) - .collect(); + if notebook_path.is_some() { + if let Ok(rd) = fs::read_dir(root) { + for entry in rd.filter_map(|e| e.ok()) { + let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false); + if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") { + if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) { + notes.push(note); + } + } + } + } + } else { + let hn_dir = helixnotes_dir(vault_path); + for entry in WalkDir::new(root) + .into_iter() + // filter_entry skips descending into hidden dirs (unlike filter) + .filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir)) + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().is_file() + && e.path().extension().and_then(|x| x.to_str()) == Some("md") + }) + { + if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) { + notes.push(note); + } + } + } + log::info!("scan_notes: Android scan found {} notes", notes.len()); + notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); + return Ok(notes); + } - notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); - Ok(notes) + #[cfg(not(target_os = "android"))] + { + let md_files: Vec = if notebook_path.is_some() { + fs::read_dir(root) + .map_err(|e| e.to_string())? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) + .collect() + } else { + WalkDir::new(root) + .into_iter() + .filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path))) + .filter_map(|e| e.ok()) + .map(|e| e.path().to_path_buf()) + .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) + .collect() + }; + + let mut notes: Vec = md_files + .par_iter() + .filter_map(|path| read_note_entry_fast(path, vault_root).ok()) + .collect(); + + notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); + Ok(notes) + } } fn read_note_entry(path: &Path, vault_root: &Path) -> Result { @@ -167,6 +259,49 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result read_note_entry_from_str(&raw, path, vault_root) } +/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned, +/// uses filesystem timestamps for dates. No preview text. +#[cfg(target_os = "android")] +fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result { + // Read first 2KB — enough for frontmatter with tags, title, pinned + let mut file = fs::File::open(path).map_err(|e| e.to_string())?; + let mut buf = vec![0u8; 2048]; + let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?; + buf.truncate(bytes_read); + let raw = String::from_utf8_lossy(&buf); + + let filename = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + let (mut meta, _content) = frontmatter::parse_note(&raw, &filename); + + // Always use filesystem timestamps on Android (faster than parsing date strings) + if let Ok(fs_meta) = fs::metadata(path) { + if let Ok(m) = fs_meta.modified() { + meta.modified = m.into(); + } + if let Ok(c) = fs_meta.created() { + meta.created = c.into(); + } + } + + let relative = path + .strip_prefix(vault_root) + .unwrap_or(path) + .to_string_lossy() + .to_string(); + + Ok(NoteEntry { + path: path.to_string_lossy().to_string(), + relative_path: relative, + meta, + preview: String::new(), + }) +} + /// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview). fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result { let mut file = fs::File::open(path).map_err(|e| e.to_string())?; diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index ec91cce..d4ab051 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -29,13 +29,16 @@ showSettings, sourceMode, mobileView, - appConfig + appConfig, + quickAccessPaths, + tags, + notes } from '$lib/stores/app'; const appWindow = getCurrentWindow(); const isMac = navigator.platform.startsWith('Mac'); const isMobile = /android|ios/i.test(navigator.userAgent); - import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile } from '$lib/api'; + import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api'; import { debounce } from '$lib/utils/debounce'; import { openNoteWindow } from '$lib/utils/window'; import { get } from 'svelte/store'; @@ -46,7 +49,12 @@ let editor: Editor; let unlistenFileChange: (() => void) | null = null; let unlistenOpenFile: (() => void) | null = null; + + // Mobile editor header helpers + let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : ''); + let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let backupInterval: ReturnType | null = null; + let navigatingFromHistory = false; let noteHistory: string[] = []; let noteHistoryIndex = -1; @@ -127,7 +135,7 @@ const persistState = debounce(async () => { const state: VaultState = { - last_open_note: null, + last_open_note: $activeNotePath, sidebar_width: $sidebarWidth, notelist_width: $notelistWidth, sidebar_collapsed: $sidebarCollapsed, @@ -154,12 +162,11 @@ } function handleViewChanged() { - noteList?.refresh(true); + noteList?.refresh(); if (isMobile) $mobileView = 'notelist'; } async function createAndFocusNote() { - if (isMobile && $mobileView !== 'notelist') $mobileView = 'notelist'; await noteList?.handleCreateNote(); editor?.focusTitle(); if (isMobile) $mobileView = 'editor'; @@ -182,6 +189,38 @@ } } + // Android back gesture / hardware back button support + let mobileNavFromPopstate = false; + + if (isMobile) { + // Seed initial history state + history.replaceState({ mobileView: 'sidebar' }, ''); + + // When mobileView changes forward, push browser history so Android back gesture works + $effect(() => { + const view = $mobileView; + if (mobileNavFromPopstate) { + mobileNavFromPopstate = false; + return; + } + // Replace state to track current view + history.pushState({ mobileView: view }, ''); + }); + + window.addEventListener('popstate', (e) => { + const currentView = $mobileView; + if (currentView === 'sidebar') { + // Already at root — let Android handle it (exit app) + return; + } + mobileNavFromPopstate = true; + if (currentView === 'editor') $mobileView = 'notelist'; + else if (currentView === 'notelist') $mobileView = 'sidebar'; + // Push state again so next back gesture also works + history.pushState({ mobileView: $mobileView }, ''); + }); + } + function mobileBack() { if ($mobileView === 'editor') $mobileView = 'notelist'; else if ($mobileView === 'notelist') $mobileView = 'sidebar'; @@ -273,28 +312,84 @@ persistState(); }); + $effect(() => { + $activeNotePath; + persistState(); + }); + onMount(async () => { + let lastNotePath: string | null = null; try { const state = await loadVaultState(); $sidebarWidth = state.sidebar_width; $notelistWidth = state.notelist_width; $sidebarCollapsed = state.sidebar_collapsed; $collapsedNotebooks = state.collapsed_notebooks ?? []; + lastNotePath = state.last_open_note ?? null; } catch (_) {} + // On mobile, prefetch last-opened note so first tap is instant + let prefetchPromise: Promise | null = null; + if (isMobile && lastNotePath) { + prefetchPromise = readNote(lastNotePath).catch(() => null); + } + applyTheme($theme); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { if ($theme === 'system') applyTheme('system'); }); - await sidebar?.refresh(); - await noteList?.refresh(); + // Run sidebar and note list refresh in parallel + await Promise.all([sidebar?.refresh(), noteList?.refresh()]); - unlistenFileChange = await listen('file-changed', async () => { - await sidebar?.refresh(); - await noteList?.refresh(true); - }); + // On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call) + if (isMobile) { + const tagMap = new Map(); + for (const note of $notes) { + for (const tag of note.meta.tags) { + tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1); + } + } + $tags = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])); + } + + // On mobile, auto-load the last-opened note so it's ready when the user taps it + if (isMobile && prefetchPromise) { + prefetchPromise.then((noteContent) => { + if (noteContent && lastNotePath && !$activeNotePath) { + $activeNote = noteContent; + $activeNotePath = lastNotePath; + $editorDirty = false; + editor?.loadNote(lastNotePath, noteContent.content); + } + }); + } + + if (isMobile) { + // Mobile: debounce file-changed heavily and skip when actively editing + // (our own saves trigger the watcher, causing expensive refreshes on FUSE) + const debouncedRefresh = debounce(async () => { + if (get(editorDirty)) return; // user is still typing, skip + await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]); + // Re-derive tags from refreshed notes + const tagMap = new Map(); + for (const note of get(notes)) { + for (const tag of note.meta.tags) { + tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1); + } + } + tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))); + }, 3000); + unlistenFileChange = await listen('file-changed', () => { + debouncedRefresh(); + }); + } else { + unlistenFileChange = await listen('file-changed', async () => { + await sidebar?.refresh(); + await noteList?.refresh(true); + }); + } // Listen for file open events (from single-instance or OS file associations) unlistenOpenFile = await listen('open-file', async (event) => { @@ -343,18 +438,18 @@ {/if} - - {#if $mobileView === 'sidebar'} - HelixNotes - {:else if $mobileView === 'notelist'} - {$activeNotebook?.name || 'All Notes'} - {:else} - {$activeNote?.meta.title || 'Untitled'} - {/if} - + {#if $mobileView !== 'editor'} + + {#if $mobileView === 'sidebar'} + HelixNotes + {:else} + {$activeNotebook?.name || 'All Notes'} + {/if} + + {/if}
{#if $mobileView === 'editor'} - + + + + + + + {#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} + + {/if} {:else} -
@@ -401,40 +546,6 @@ - - {#if $mobileView !== 'editor'} -
- - - - -
- {/if} {:else} @@ -641,6 +752,7 @@ display: flex; align-items: center; gap: 2px; + margin-left: auto; } .mobile-header-btn { @@ -685,38 +797,4 @@ pointer-events: auto; } - .mobile-bottom-nav { - display: flex; - align-items: center; - justify-content: space-around; - height: 56px; - background: var(--bg-secondary); - border-top: 1px solid var(--border-color); - flex-shrink: 0; - padding-bottom: env(safe-area-inset-bottom); - } - - .mobile-nav-item { - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - padding: 6px 16px; - border: none; - background: none; - color: var(--text-tertiary); - font-size: 10px; - font-weight: 500; - cursor: pointer; - border-radius: 8px; - transition: color 0.15s; - } - - .mobile-nav-item:active { - background: var(--bg-hover); - } - - .mobile-nav-item.active { - color: var(--accent); - } diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 59adca1..03274c7 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -52,7 +52,7 @@ let keyboardHeight = $state(0); if (isMobile && typeof window !== 'undefined' && window.visualViewport) { const vv = window.visualViewport; - const update = () => { keyboardHeight = Math.max(0, Math.round(window.innerHeight - vv.height)); }; + const update = () => { keyboardHeight = Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)); }; vv.addEventListener('resize', update); vv.addEventListener('scroll', update); } @@ -70,6 +70,7 @@ let ignoreNextUpdate = false; let isLoadingNote = false; let fixingBlobsPromise: Promise = Promise.resolve(); + let hasPendingBlobs = false; let lastSourceMode = $sourceMode; let linkContextMenu = $state<{ x: number; y: number; href: string; anchor: HTMLAnchorElement } | null>(null); let titleWasStripped = false; @@ -297,6 +298,17 @@ renderHTML({ HTMLAttributes }) { const src = HTMLAttributes.src || ''; const name = HTMLAttributes.name || 'file.pdf'; + if (isMobile) { + // On mobile, render as a clickable link instead of iframe + const vaultRoot = $appConfig?.active_vault ?? ''; + const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`); + return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }), + ['a', { href: '#', 'data-open-file': absPath, class: 'pdf-link-mobile', onclick: 'return false;' }, + ['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'], + ['span', {}, name], + ], + ]; + } const vaultRoot = $appConfig?.active_vault ?? ''; const pdfHeight = $appConfig?.pdf_height ?? 600; const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`); @@ -1058,7 +1070,11 @@ const autoSave = debounce(async () => { if (!$activeNote || !$activeNotePath || !$editorDirty) return; - // Wait for any pending blob→asset conversions to finish before serializing + // Only fix blob images if a paste occurred (avoids full doc scan on every save) + if (hasPendingBlobs) { + hasPendingBlobs = false; + fixingBlobsPromise = fixBlobImages(); + } await fixingBlobsPromise; try { const body = $sourceMode @@ -1075,7 +1091,7 @@ } catch (e) { console.error('Auto-save failed:', e); } - }, 500); + }, isMobile ? 1500 : 500); export async function forceSave() { if (!$activeNote || !$activeNotePath) return; @@ -1872,11 +1888,14 @@ return `![${alt}](${url.replace(/ /g, '%20')})`; }); - // Pre-process: transform PDF embed divs into iframes before markdown-it (it passes HTML through) + // Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile src = src.replace(/]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => { const vaultRoot = $appConfig?.active_vault ?? ''; - const pdfHeight = $appConfig?.pdf_height ?? 600; const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`); + if (isMobile) { + return ``; + } + const pdfHeight = $appConfig?.pdf_height ?? 600; const displaySrc = convertFileSrc(absPath); return `

${name}

`; }); @@ -1972,11 +1991,16 @@ return str.replace(/&/g, '&').replace(//g, '>'); } - // When editorElement appears in DOM (first note selected), initialize TipTap + // When editorElement appears in DOM, initialize TipTap. + // On mobile, pre-create editor with empty content so first note load is fast. $effect(() => { - if (editorElement && !editor && pendingContent !== null) { - createEditor(pendingContent); - pendingContent = null; + if (editorElement && !editor) { + if (pendingContent !== null) { + createEditor(pendingContent); + pendingContent = null; + } else if (isMobile) { + createEditor(''); + } } }); @@ -2094,6 +2118,20 @@ } } }, + // On mobile, handle clicks on PDF file links to open in external app + ...(isMobile ? { click: (_view: any, event: MouseEvent) => { + const target = (event.target as HTMLElement).closest('[data-open-file]') as HTMLElement | null; + if (target) { + event.preventDefault(); + const filePath = target.getAttribute('data-open-file'); + if (filePath) { + const bridge = (window as any).Android; + if (bridge && typeof bridge.openFile === 'function') { + bridge.openFile(filePath); + } + } + } + }} : {}), // Prevent native text drag — it causes copy-instead-of-move in Tauri's webview. // File drops from OS are handled by Tauri's onDragDropEvent listener instead. dragstart: (_view, event) => { @@ -2104,7 +2142,11 @@ }, }, handleDrop: (_view, event) => handleFileDrop(event), - handlePaste: (_view, event) => handleFilePaste(event), + handlePaste: (_view, event) => { + const handled = handleFilePaste(event); + if (!handled) hasPendingBlobs = true; // ProseMirror may insert blob: images from web paste + return handled; + }, }, onTransaction: () => { // Batch toolbar state updates to once per frame — avoids ~35 isActive() calls per transaction during selection drag @@ -2114,8 +2156,9 @@ editorState++; }); } - updateSlashMenu(); - updateWikiLinkMenu(); + // On mobile, only check menus when they're already open (avoid work on every keystroke) + if (!isMobile || slashMenu) updateSlashMenu(); + if (!isMobile || wikiLinkMenu) updateWikiLinkMenu(); }, onUpdate: () => { if (ignoreNextUpdate || isLoadingNote) { @@ -2123,10 +2166,8 @@ return; } $editorDirty = true; - // Fix any blob: URLs from pasted web images BEFORE saving - fixingBlobsPromise = fixBlobImages(); autoSave(); - if (showOutline) updateOutline(); + if (!isMobile && showOutline) updateOutline(); }, }); editorReady = true; @@ -2136,6 +2177,19 @@ } } + export function toggleOutlinePanel() { + showOutline = !showOutline; + if (showOutline) updateOutline(); + } + + export function toggleHistoryPanel() { + toggleHistory(); + } + + export function triggerAiMenu() { + openAiMenu(); + } + export function addLinkFromToolbar() { if (!editor) return; const { from, to } = editor.state.selection; @@ -2245,7 +2299,14 @@ if (href) { event.preventDefault(); event.stopPropagation(); - linkContextMenu = { x: event.clientX, y: event.clientY, href, anchor }; + let lx = event.clientX; + let ly = event.clientY; + const lw = 200, lh = 200; + if (lx + lw > window.innerWidth) lx = window.innerWidth - lw - 8; + if (ly + lh > window.innerHeight) ly = window.innerHeight - lh - 8; + if (lx < 4) lx = 4; + if (ly < 4) ly = 4; + linkContextMenu = { x: lx, y: ly, href, anchor }; return; } } @@ -2492,7 +2553,10 @@ aiTranslateMenu = false; aiCustomPrompt = ''; - if (hasSelection) { + if (isMobile) { + // Mobile: bottom sheet, no positioning needed + aiMenu = { x: 0, y: 0 }; + } else if (hasSelection) { const coords = editor.view.coordsAtPos(from); let x = coords.left; let y = coords.top - 8; @@ -2803,7 +2867,7 @@ const data = Array.from(new Uint8Array(buffer)); const relativePath = await saveAttachment(file.name, data); if (!editor) return; - const usePdfPreview = $appConfig?.pdf_preview ?? false; + const usePdfPreview = !isMobile && ($appConfig?.pdf_preview ?? false); if (usePdfPreview) { editor.chain().focus().insertContent({ type: 'pdfEmbed', @@ -2898,16 +2962,25 @@ resetSourceHistory(sourceContent); lastSourceMode = true; } else if (!isSource && lastSourceMode) { - // Switching BACK to WYSIWYG: destroy old editor (its DOM element is gone), - // wait for DOM to swap textarea→div, then create editor on new element. - destroyEditor(); - const content = sourceContent || ($activeNote?.content ?? ''); lastSourceMode = false; - tick().then(() => { - if (editorElement && !editor) { - createEditor(content); + if (isMobile) { + // Mobile: editor stays in DOM, just update its content + const content = sourceContent || ($activeNote?.content ?? ''); + if (editor) { + ignoreNextUpdate = true; + editor.commands.setContent(markdownToHtml(content)); } - }); + } else { + // Desktop: destroy old editor (its DOM element is gone), + // wait for DOM to swap textarea→div, then create editor on new element. + destroyEditor(); + const content = sourceContent || ($activeNote?.content ?? ''); + tick().then(() => { + if (editorElement && !editor) { + createEditor(content); + } + }); + } } }); @@ -2933,7 +3006,7 @@ readFile(filePath).then((data) => { saveAttachment(name, Array.from(data)).then((relativePath) => { if (!editor) return; - const usePdfPreview = $appConfig?.pdf_preview ?? false; + const usePdfPreview = !isMobile && ($appConfig?.pdf_preview ?? false); if (usePdfPreview) { editor.chain().focus().insertContent({ type: 'pdfEmbed', @@ -3039,14 +3112,14 @@ }} /> -
+ {#if !isMobile} +
{#if $editorDirty} Unsaved {/if} {#if $readOnly} View Mode {/if} - {#if !isMobile} - {/if} @@ -3105,7 +3177,7 @@ onclick={() => { showOutline = !showOutline; if (showOutline) updateOutline(); }} title="Outline" > - + @@ -3115,12 +3187,11 @@ onclick={toggleHistory} title="Version history" > - + - {#if !isMobile} {#if $appConfig?.enable_wiki_links} - {/if}
+ {/if}
@@ -3196,19 +3267,11 @@ {/if}
- {#if $sourceMode} - {#if $appConfig?.show_line_numbers} - - {/if} + {#if isMobile} + - {:else} -
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}>
+
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}>
+ {:else} + + {#if $sourceMode} + {#if $appConfig?.show_line_numbers} + + {/if} + + {:else} + +
{ closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}>
+ {/if} {/if}
@@ -3383,10 +3504,44 @@ {#if editorReady && !$sourceMode} -
0 ? `bottom: ${keyboardHeight}px` : ''} onclick={() => { headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }}> +
0 ? `bottom: ${keyboardHeight}px;` : ''}${anyDropdownOpen ? 'overflow: visible;' : ''}` : ''} onclick={() => { headingDropdown = false; colorDropdown = false; highlightDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }}> {#if isMobile} + +
+ + {#if insertDropdown} + +
e.stopPropagation()}> + + + + + +
+ {/if} +
+ +
+
+ {#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
- - -
- - {#if insertDropdown} - -
e.stopPropagation()}> - - - - - -
- {/if} -
+ + {/if} {:else} @@ -4155,9 +4285,9 @@ {#if aiMenu} -
+
-
e.stopPropagation()}> +
e.stopPropagation()}> {#if aiResult !== null || aiLoading}
@@ -5942,6 +6072,30 @@ border-top: 1px solid var(--border); margin: 0; } + :global(.tiptap .pdf-embed-mobile) { + margin: 8px 0; + } + :global(.tiptap .pdf-link-mobile) { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 10px; + color: var(--text-primary); + font-size: 15px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + } + :global(.tiptap .pdf-link-mobile:active) { + background: var(--bg-hover); + } + :global(.tiptap .pdf-icon-mobile) { + font-size: 20px; + line-height: 1; + } /* Slash commands menu */ .slash-menu-overlay { @@ -6367,7 +6521,7 @@ } .editor-container.mobile .editor-toolbar { - padding: 8px 8px 6px 12px; + padding: 8px 16px 6px 16px; flex-shrink: 0; flex-direction: column; align-items: stretch; @@ -6410,11 +6564,11 @@ min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch; - padding-bottom: 50px; + padding: 0; } .editor-container.mobile .tiptap-wrapper { - min-height: 100%; + min-height: 0; } .editor-container.mobile .editor-formatting-bar { @@ -6451,11 +6605,11 @@ } .editor-container.mobile .fmt-dropdown { - position: fixed; - bottom: auto; - left: 8px; - right: 8px; - top: auto; + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: auto; + min-width: 180px; max-width: calc(100vw - 16px); max-height: 60vh; overflow-y: auto; @@ -6468,10 +6622,11 @@ } .editor-container.mobile .insert-dropdown { - position: fixed; - bottom: 60px; - left: 8px; - right: 8px; + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: auto; + min-width: 200px; max-width: calc(100vw - 16px); } @@ -6484,21 +6639,30 @@ } .editor-container.mobile :global(.editor-content) { - padding: 8px 16px !important; + padding: 8px 16px 220px !important; font-size: 16px !important; } .editor-container.mobile .source-editor { - padding: 8px 16px; + padding: 8px 16px 220px; font-size: 15px; + white-space: pre-wrap; + word-break: break-word; + overflow-x: hidden; + } + + .editor-container.mobile .editor-body-row { + position: relative; } .editor-container.mobile .history-panel, .editor-container.mobile .outline-panel { + position: absolute; + inset: 0; width: 100% !important; max-width: 100%; border-left: none; - border-top: 1px solid var(--border-color); + z-index: 10; } .editor-container.mobile .note-search-bar { @@ -6509,4 +6673,92 @@ padding: 8px 10px; font-size: 15px; } + + /* ═══ AI Menu — Mobile Bottom Sheet ═══ */ + .ai-menu-overlay.mobile { + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: flex-end; + justify-content: center; + } + + .ai-menu.mobile { + position: relative; + left: auto !important; + top: auto !important; + width: 100%; + max-width: 100%; + min-width: 0; + border-radius: 16px 16px 0 0; + border-bottom: none; + max-height: 70vh; + padding: 8px 4px calc(env(safe-area-inset-bottom, 0px) + 8px); + } + + .ai-menu.mobile .ai-menu-label { + padding: 10px 16px 6px; + font-size: 12px; + } + + .ai-menu.mobile .ai-menu-item { + padding: 12px 16px; + font-size: 15px; + min-height: 44px; + border-radius: 8px; + } + + .ai-menu.mobile .ai-menu-sep { + margin: 4px 8px; + } + + .ai-menu.mobile .ai-result-header { + padding: 12px 16px 8px; + } + + .ai-menu.mobile .ai-result-body { + padding: 8px 16px; + font-size: 15px; + max-height: 40vh; + } + + .ai-menu.mobile .ai-result-actions { + padding: 8px 16px 4px; + gap: 10px; + } + + .ai-menu.mobile .ai-result-actions button { + padding: 10px 16px; + font-size: 14px; + min-height: 44px; + } + + .ai-menu.mobile .ai-custom-body { + padding: 8px 12px; + } + + .ai-menu.mobile .ai-custom-input { + font-size: 15px; + min-height: 80px; + } + + .ai-menu.mobile .ai-custom-submit { + padding: 10px 16px; + font-size: 14px; + min-height: 44px; + } + + .ai-menu.mobile .ai-custom-header { + padding: 10px 12px; + font-size: 15px; + } + + .ai-menu.mobile .ai-back-btn { + min-width: 44px; + min-height: 44px; + } + + .ai-menu.mobile .ai-error { + padding: 12px 16px; + font-size: 14px; + } diff --git a/src/lib/components/InfoPanel.svelte b/src/lib/components/InfoPanel.svelte index 7346414..903a050 100644 --- a/src/lib/components/InfoPanel.svelte +++ b/src/lib/components/InfoPanel.svelte @@ -6,9 +6,10 @@ import type { VaultStats } from '$lib/types'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; + const isMobile = /android|ios/i.test(navigator.userAgent); let stats = $state(null); - let activeTab = $state<'about' | 'shortcuts'>('shortcuts'); + let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let appVersion = $state('...'); getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0'); @@ -54,10 +55,12 @@
+ {#if !isMobile}
+ {/if} {#if activeTab === 'about'}