Compare commits

..
30 Commits
Author SHA1 Message Date
Yuri Karamian db92dabba5 chore: bump version to 1.3.3 2026-06-28 13:31:49 +02:00
Yuri Karamian 2469a971ca WebDAV sync: make config per-vault so switching vaults no longer cross-contaminates remotes; migrate existing global config to the active vault 2026-06-27 11:33:07 +02:00
Yuri Karamian a43bf05bf0 WebDAV sync: make config per-vault so switching vaults no longer cross-contaminates remotes; migrate existing global config
to the active vault
2026-06-27 01:08:02 +02:00
blake ffd9ebad7e The outline side panel now persists when a user clicks in the editor. (#176)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/176
2026-06-27 00:00:16 +02:00
mf d2100c8500 Create notes from empty list double-click (#175)
Double-clicking empty space in the notes list now creates a new note. The notebook sidebar double-click shortcut is scoped to the notebook section so adjacent panes do not trigger notebook creation.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/175
2026-06-26 23:59:54 +02:00
Yuri Karamian 7e63d721bc Fix 'Open in New Window' cursor jumping to the top when pausing (ignore the file-watcher echo of the window's own save) 2026-06-25 16:08:25 +02:00
Yuri Karamian 2c774c51c6 Notebook reorder: make path math separator-agnostic so manual sort works on Windows (backslash paths) 2026-06-25 15:10:20 +02:00
Yuri Karamian 8eba4527af Notebook reorder: commit drop on any release event and skip pointer capture on mouse, so it works in WebView2 (Windows) 2026-06-25 14:55:59 +02:00
Yuri Karamian 181b3eb6c2 Notebook reorder: track drag on window pointer events so it works in WebView2 (Windows) 2026-06-25 14:47:06 +02:00
Yuri Karamian 879d2f42c6 Notebook reorder via pointer-event drag handle (fixes Windows/Android); click body selects, chevron expands 2026-06-25 13:31:49 +02:00
Yuri Karamian 85ea26b57a Notebook reorder: use pointer events via a drag handle so manual sort works on Windows and Android 2026-06-25 13:29:24 +02:00
Yuri Karamian 800c81e598 Add ! menu on task lines for setting priority and due date 2026-06-25 12:57:51 +02:00
Yuri Karamian 30c3523722 Parallelize vault-stats and tasks scanning for faster Info/Tasks loads on Android 2026-06-25 12:37:32 +02:00
Yuri Karamian 761bf5b42e Android: add an Info button to the top bar that opens the About panel 2026-06-25 12:37:12 +02:00
Yuri Karamian b10a7b1bfc Fix typing stutter in large notes by debouncing per-keystroke document scans 2026-06-25 12:09:06 +02:00
blake 313d86df23 Show all existing tags when tag input is empty (#165)
WIP: Previously suggestions only appeared after typing; now all unused tags can be shown immediately when the tag menu opens.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/165
2026-06-25 11:45:53 +02:00
Yuri Karamian 95627cbb5c Fix ordered list markers and empty-callout placeholder on WebKitGTK 2026-06-25 11:43:08 +02:00
Yuri Karamian b9ea14f330 README: document the macOS damaged-app fix (xattr) 2026-06-25 11:42:53 +02:00
Yuri Karamian 812d025a9d Fix ordered list markers showing only the last digit on WebKitGTK 2026-06-25 00:33:58 +02:00
Yuri Karamian 11cc2daa4b Parallelize notebook and note scanning for faster loads on Android 2026-06-24 23:28:28 +02:00
Blake Boone 5c36f2aa71 Added collapsable info panel that tracks word/character count, snapshots, last modified, etc. (#164)
WIP

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/164
2026-06-24 22:54:52 +02:00
Yuri Karamian 987b1b9ed7 Add opt-in date grouping for the All Notes list 2026-06-24 22:38:23 +02:00
Yuri Karamian 3ef0e14581 Add callouts (> [!type]) with fold, titles, type picker, and custom types 2026-06-24 21:57:13 +02:00
mf f98b3415bc feat(editor): resizable outline panel (#161)
Add a drag handle to resize the outline panel and persist its width in
vault state (outline_width). Outline text now scales with the editor
font size.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/161
2026-06-24 21:01:43 +02:00
mf 16805c96aa Delete active note on shortcut (#156)
Press `ctrl+shift+del` to move the active note to the trash.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/156
2026-06-24 21:00:33 +02:00
Yuri Karamian 9f2e0ba8b6 README: add macOS Apple Silicon (aarch64) download link 2026-06-24 20:05:56 +02:00
blake 7b3609afbb Added custom themes, import/export option with json (#159)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/159
2026-06-23 20:19:23 +02:00
Yuri Karamian 84ff75f223 Fix #157: don't flag notebook folder icons as orphaned attachments 2026-06-23 14:03:29 +02:00
Yuri Karamian 56dc0eefd7 Fix #63 desktop/mobile layout; hide Updates tab on Android; dim inline task tokens in the editor 2026-06-23 13:52:26 +02:00
Yuri Karamian eeea16531a Fix #63: decide layout from compile-time platform, not the webview user-agent 2026-06-23 13:12:36 +02:00
29 changed files with 2736 additions and 372 deletions
+9 -1
View File
@@ -104,7 +104,15 @@ sudo eopkg it helixnotes
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta) [Download .dmg (Apple Silicon)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
>
> ```bash
> xattr -cr /Applications/HelixNotes.app
> ```
### Android ### Android
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.3.2", "version": "1.3.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.3.2" version = "1.3.3"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.3.2" version = "1.3.3"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
+21
View File
@@ -0,0 +1,21 @@
{
"themes": [
{
"colors": {
"bg_active": "#373a52",
"bg_editor": "#1a1b26",
"bg_hover": "#2f3145",
"bg_primary": "#1a1b26",
"bg_secondary": "#1f2029",
"bg_tertiary": "#2a2b3d",
"border_color": "#2f3145",
"text_primary": "#c8cde4",
"text_secondary": "#6b7094"
},
"id": "custom-1782146306429",
"is_dark": true,
"name": "My theme"
}
],
"version": 1
}
+173 -64
View File
@@ -43,6 +43,7 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
config.vaults.push(VaultConfig { config.vaults.push(VaultConfig {
path: path.clone(), path: path.clone(),
name, name,
..Default::default()
}); });
} }
config.active_vault = Some(path); config.active_vault = Some(path);
@@ -90,6 +91,58 @@ pub fn set_accent_color(state: State<'_, AppState>, color: String) -> Result<(),
Ok(()) Ok(())
} }
#[tauri::command]
pub fn save_custom_theme(state: State<'_, AppState>, theme: crate::types::CustomTheme) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
config.custom_themes[pos] = theme;
} else {
config.custom_themes.push(theme);
}
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn delete_custom_theme(state: State<'_, AppState>, id: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.custom_themes.retain(|t| t.id != id);
if config.theme == id {
config.theme = "system".to_string();
}
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn export_custom_theme(state: State<'_, AppState>, id: String, path: String) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let theme = config.custom_themes.iter().find(|t| t.id == id)
.ok_or_else(|| "Theme not found".to_string())?;
let export = serde_json::json!({ "version": 1, "themes": [theme] });
let data = serde_json::to_string_pretty(&export).map_err(|e| e.to_string())?;
std::fs::write(&path, data).map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
pub fn import_custom_themes(state: State<'_, AppState>, path: String) -> Result<Vec<crate::types::CustomTheme>, String> {
let data = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let parsed: serde_json::Value = serde_json::from_str(&data).map_err(|e| e.to_string())?;
let themes: Vec<crate::types::CustomTheme> = serde_json::from_value(parsed["themes"].clone())
.map_err(|e| format!("Invalid theme file: {}", e))?;
let mut config = state.config.lock().map_err(|e| e.to_string())?;
for theme in &themes {
if let Some(pos) = config.custom_themes.iter().position(|t| t.id == theme.id) {
config.custom_themes[pos] = theme.clone();
} else {
config.custom_themes.push(theme.clone());
}
}
save_app_config(&config)?;
Ok(themes)
}
#[tauri::command] #[tauri::command]
pub fn set_font_size(state: State<'_, AppState>, size: u32) -> Result<(), String> { pub fn set_font_size(state: State<'_, AppState>, size: u32) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -612,6 +665,7 @@ fn extract_title_fast(raw: &str) -> Option<String> {
#[tauri::command] #[tauri::command]
pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> { pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> {
use rayon::prelude::*;
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path); let vault = std::path::Path::new(vault_path);
@@ -621,46 +675,44 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap(); let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap();
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap(); let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap();
let mut tasks = Vec::new(); let paths: Vec<std::path::PathBuf> = walkdir::WalkDir::new(vault)
for entry in walkdir::WalkDir::new(vault)
.into_iter() .into_iter()
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir)) .filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ .filter(|e| {
let path = entry.path(); e.file_type().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md")
if !path.is_file() { })
continue; .map(|e| e.into_path())
} .collect();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue; // Read and parse each note in parallel; on Android every read_to_string is a FUSE round-trip.
} let tasks: Vec<crate::types::TaskItem> = paths
.par_iter()
.flat_map(|path| {
let mut out: Vec<crate::types::TaskItem> = Vec::new();
let raw = match std::fs::read_to_string(path) { let raw = match std::fs::read_to_string(path) {
Ok(r) => r, Ok(r) => r,
Err(_) => continue, Err(_) => return out,
}; };
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string(); let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename); let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let note_path = path.to_string_lossy().to_string(); let note_path = path.to_string_lossy().to_string();
for (i, line) in body.lines().enumerate() { for (i, line) in body.lines().enumerate() {
let caps = match task_re.captures(line) { let caps = match task_re.captures(line) {
Some(c) => c, Some(c) => c,
None => continue, None => continue,
}; };
let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open let completed = !caps[1].trim().is_empty();
let content = caps[2].to_string(); let content = caps[2].to_string();
let due = due_re.captures(&content).map(|c| c[1].to_string()); let due = due_re.captures(&content).map(|c| c[1].to_string());
let priority = prio_re.captures(&content).map(|c| { let priority = prio_re.captures(&content).map(|c| {
let p = c[1].to_lowercase(); let p = c[1].to_lowercase();
if p == "medium" { "med".to_string() } else { p } if p == "medium" { "med".to_string() } else { p }
}); });
// Strip metadata tokens for the display text, then collapse whitespace.
let mut text = due_re.replace_all(&content, "").to_string(); let mut text = due_re.replace_all(&content, "").to_string();
text = prio_re.replace_all(&text, " ").to_string(); text = prio_re.replace_all(&text, " ").to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" "); let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
out.push(crate::types::TaskItem {
tasks.push(crate::types::TaskItem {
note_path: note_path.clone(), note_path: note_path.clone(),
note_title: meta.title.clone(), note_title: meta.title.clone(),
line: i, line: i,
@@ -671,7 +723,9 @@ pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskIte
priority, priority,
}); });
} }
} out
})
.collect();
Ok(tasks) Ok(tasks)
} }
@@ -1171,41 +1225,38 @@ pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec<String>) -> R
#[tauri::command] #[tauri::command]
pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> { pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> {
use rayon::prelude::*;
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let mut total_notes: u64 = 0; let paths: Vec<std::path::PathBuf> = walkdir::WalkDir::new(vault_path)
let mut total_attachments: u64 = 0;
let mut notes_size: u64 = 0;
let mut attachments_size: u64 = 0;
for entry in walkdir::WalkDir::new(vault_path)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ .filter(|e| e.file_type().is_file())
let path = entry.path(); .map(|e| e.into_path())
if !path.is_file() { .filter(|p| {
continue; // attachments under .helixnotes count; nothing else in .helixnotes or .trash does
} let s = p.to_string_lossy();
let path_str = path.to_string_lossy(); !(s.contains("/.helixnotes/") && !s.contains("/.helixnotes/attachments/"))
// Include .helixnotes/attachments, skip everything else in .helixnotes && !s.contains("/.trash/")
if path_str.contains("/.helixnotes/") && !path_str.contains("/.helixnotes/attachments/") { })
continue; .collect();
}
// Skip trash // Stat files in parallel; each metadata() is a FUSE round-trip on Android.
if path_str.contains("/.trash/") { let (total_notes, total_attachments, notes_size, attachments_size) = paths
continue; .par_iter()
} .map(|path| {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0); let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); if path.extension().and_then(|e| e.to_str()) == Some("md") {
if ext == "md" { (1u64, 0u64, size, 0u64)
total_notes += 1;
notes_size += size;
} else { } else {
total_attachments += 1; (0u64, 1u64, 0u64, size)
attachments_size += size;
}
} }
})
.reduce(
|| (0u64, 0u64, 0u64, 0u64),
|a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2, a.3 + b.3),
);
Ok(VaultStats { Ok(VaultStats {
total_notes, total_notes,
@@ -1688,9 +1739,9 @@ pub struct OrphanAttachment {
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every // Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files) // .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// and matches each filename against both the raw note text and a percent-decoded copy (so a // plus notebook_icons.json, and matches each filename against both the raw text and a
// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is // percent-decoded copy (so a URL-encoded path like `my%20file.png` still counts as a reference).
// KEPT: a leftover orphan is harmless, a wrong deletion is not. // When in doubt a file is KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> { fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
let attachments_dir = operations::helixnotes_dir(vault).join("attachments"); let attachments_dir = operations::helixnotes_dir(vault).join("attachments");
if !attachments_dir.is_dir() { if !attachments_dir.is_dir() {
@@ -1719,6 +1770,12 @@ fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String>
} }
} }
} }
// Folder icons live in attachments but are referenced here, not in notes. (#157)
let icons_path = operations::helixnotes_dir(vault).join("notebook_icons.json");
if let Ok(content) = std::fs::read_to_string(&icons_path) {
haystack.push_str(&content);
haystack.push('\n');
}
let decoded = percent_decode(&haystack); let decoded = percent_decode(&haystack);
let orphans = files let orphans = files
.into_iter() .into_iter()
@@ -2144,19 +2201,29 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
// ── Sync (WebDAV) ── // ── Sync (WebDAV) ──
fn active_vault_config(config: &AppConfig) -> Result<&VaultConfig, String> {
let active = config.active_vault.as_deref().ok_or("No active vault")?;
config
.vaults
.iter()
.find(|v| v.path == active)
.ok_or_else(|| "Active vault not found in config".to_string())
}
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> { fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
if config.sync_provider.as_deref() != Some("webdav") { let v = active_vault_config(config)?;
if v.sync_provider.as_deref() != Some("webdav") {
return Err("Sync is not configured".to_string()); return Err("Sync is not configured".to_string());
} }
let url = config let url = v
.webdav_url .webdav_url
.clone() .clone()
.filter(|u| !u.trim().is_empty()) .filter(|u| !u.trim().is_empty())
.ok_or("WebDAV URL is not set")?; .ok_or("WebDAV URL is not set")?;
Ok(crate::sync::WebdavConfig { Ok(crate::sync::WebdavConfig {
url, url,
username: config.webdav_username.clone().unwrap_or_default(), username: v.webdav_username.clone().unwrap_or_default(),
password: config.webdav_password.clone().unwrap_or_default(), password: v.webdav_password.clone().unwrap_or_default(),
}) })
} }
@@ -2172,13 +2239,19 @@ pub fn set_sync_settings(
sync_interval_minutes: u32, sync_interval_minutes: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.sync_provider = provider.filter(|p| !p.is_empty()); let active = config.active_vault.clone().ok_or("No active vault")?;
config.webdav_url = url.filter(|u| !u.trim().is_empty()); let v = config
config.webdav_username = username.filter(|u| !u.is_empty()); .vaults
config.webdav_password = password.filter(|p| !p.is_empty()); .iter_mut()
config.sync_on_open = sync_on_open; .find(|v| v.path == active)
config.sync_on_change = sync_on_change; .ok_or_else(|| "Active vault not found in config".to_string())?;
config.sync_interval_minutes = sync_interval_minutes; v.sync_provider = provider.filter(|p| !p.is_empty());
v.webdav_url = url.filter(|u| !u.trim().is_empty());
v.webdav_username = username.filter(|u| !u.is_empty());
v.webdav_password = password.filter(|p| !p.is_empty());
v.sync_on_open = sync_on_open;
v.sync_on_change = sync_on_change;
v.sync_interval_minutes = sync_interval_minutes;
save_app_config(&config)?; save_app_config(&config)?;
Ok(()) Ok(())
} }
@@ -2242,13 +2315,15 @@ pub fn sync_now(app: AppHandle) -> Result<(), String> {
}; };
std::thread::spawn(move || { std::thread::spawn(move || {
use tauri::Emitter; use tauri::Emitter;
let result = crate::sync::run_sync(app.clone(), vault, cfg); let result = crate::sync::run_sync(app.clone(), vault.clone(), cfg);
app.state::<AppState>().syncing.store(false, Ordering::SeqCst); app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
match result { match result {
Ok(summary) => { Ok(summary) => {
let ts = chrono::Utc::now().to_rfc3339(); let ts = chrono::Utc::now().to_rfc3339();
if let Ok(mut config) = app.state::<AppState>().config.lock() { if let Ok(mut config) = app.state::<AppState>().config.lock() {
config.last_sync_time = Some(ts.clone()); if let Some(v) = config.vaults.iter_mut().find(|v| v.path == vault) {
v.last_sync_time = Some(ts.clone());
}
let _ = save_app_config(&config); let _ = save_app_config(&config);
} }
let _ = app.emit( let _ = app.emit(
@@ -2368,11 +2443,45 @@ fn app_config_path() -> Result<std::path::PathBuf, String> {
} }
pub fn load_app_config() -> AppConfig { pub fn load_app_config() -> AppConfig {
app_config_path() let mut config: AppConfig = app_config_path()
.ok() .ok()
.and_then(|p| std::fs::read_to_string(p).ok()) .and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default() .unwrap_or_default();
if migrate_global_sync_to_vault(&mut config) {
let _ = save_app_config(&config);
}
config
}
// One-time migration: WebDAV sync moved from global AppConfig to per-vault VaultConfig.
// Copy the old global settings into the active vault's config if it has none yet. Idempotent.
fn migrate_global_sync_to_vault(config: &mut AppConfig) -> bool {
if config.sync_provider.is_none() && config.webdav_url.is_none() {
return false;
}
let Some(active) = config.active_vault.clone() else { return false; };
let g_provider = config.sync_provider.clone();
let g_url = config.webdav_url.clone();
let g_user = config.webdav_username.clone();
let g_pass = config.webdav_password.clone();
let g_on_open = config.sync_on_open;
let g_on_change = config.sync_on_change;
let g_interval = config.sync_interval_minutes;
let g_last = config.last_sync_time.clone();
let Some(v) = config.vaults.iter_mut().find(|v| v.path == active) else { return false; };
if v.sync_provider.is_some() || v.webdav_url.is_some() {
return false; // already migrated / has its own config
}
v.sync_provider = g_provider;
v.webdav_url = g_url;
v.webdav_username = g_user;
v.webdav_password = g_pass;
v.sync_on_open = g_on_open;
v.sync_on_change = g_on_change;
v.sync_interval_minutes = g_interval;
v.last_sync_time = g_last;
true
} }
fn save_app_config(config: &AppConfig) -> Result<(), String> { fn save_app_config(config: &AppConfig) -> Result<(), String> {
+18
View File
@@ -38,7 +38,21 @@ pub fn run() {
let close_to_tray = config.close_to_tray && show_tray; let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config); let app_state = AppState::new(config);
// Inject the compile-time platform so the frontend never sniffs the (sometimes
// mobile-looking) WebKitGTK user-agent. (#63)
let platform_init = format!(
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
cfg!(mobile),
cfg!(target_os = "android"),
cfg!(target_os = "ios"),
);
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
.js_init_script(platform_init)
.build(),
)
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@@ -102,6 +116,10 @@ pub fn run() {
commands::get_app_config, commands::get_app_config,
commands::set_theme, commands::set_theme,
commands::set_accent_color, commands::set_accent_color,
commands::save_custom_theme,
commands::delete_custom_theme,
commands::export_custom_theme,
commands::import_custom_themes,
commands::set_font_size, commands::set_font_size,
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
+50 -2
View File
@@ -50,10 +50,48 @@ pub struct NoteContent {
pub raw: String, pub raw: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VaultConfig { pub struct VaultConfig {
pub path: String, pub path: String,
pub name: String, pub name: String,
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
#[serde(default)]
pub sync_provider: Option<String>,
#[serde(default)]
pub webdav_url: Option<String>,
#[serde(default)]
pub webdav_username: Option<String>,
#[serde(default)]
pub webdav_password: Option<String>,
#[serde(default)]
pub sync_on_open: bool,
#[serde(default)]
pub sync_on_change: bool,
#[serde(default)]
pub sync_interval_minutes: u32,
#[serde(default)]
pub last_sync_time: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomThemeColors {
pub bg_primary: String,
pub bg_secondary: String,
pub bg_tertiary: String,
pub bg_hover: String,
pub bg_active: String,
pub bg_editor: String,
pub text_primary: String,
pub text_secondary: String,
pub border_color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomTheme {
pub id: String,
pub name: String,
pub is_dark: bool,
pub colors: CustomThemeColors,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -151,7 +189,7 @@ pub struct AppConfig {
pub enable_wiki_links: bool, pub enable_wiki_links: bool,
#[serde(default)] #[serde(default)]
pub restore_last_session: bool, pub restore_last_session: bool,
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured. // DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
#[serde(default)] #[serde(default)]
pub sync_provider: Option<String>, pub sync_provider: Option<String>,
#[serde(default)] #[serde(default)]
@@ -168,12 +206,18 @@ pub struct AppConfig {
pub sync_interval_minutes: u32, pub sync_interval_minutes: u32,
#[serde(default)] #[serde(default)]
pub last_sync_time: Option<String>, pub last_sync_time: Option<String>,
#[serde(default)]
pub custom_themes: Vec<CustomTheme>,
} }
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
fn default_outline_width() -> f64 {
220.0
}
fn default_pdf_height() -> u32 { fn default_pdf_height() -> u32 {
600 600
} }
@@ -265,6 +309,7 @@ impl Default for AppConfig {
sync_on_change: false, sync_on_change: false,
sync_interval_minutes: 0, sync_interval_minutes: 0,
last_sync_time: None, last_sync_time: None,
custom_themes: Vec::new(),
} }
} }
} }
@@ -274,6 +319,8 @@ pub struct VaultState {
pub last_open_note: Option<String>, pub last_open_note: Option<String>,
pub sidebar_width: f64, pub sidebar_width: f64,
pub notelist_width: f64, pub notelist_width: f64,
#[serde(default = "default_outline_width")]
pub outline_width: f64,
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
#[serde(default)] #[serde(default)]
pub notelist_collapsed: bool, pub notelist_collapsed: bool,
@@ -309,6 +356,7 @@ impl Default for VaultState {
last_open_note: None, last_open_note: None,
sidebar_width: 220.0, sidebar_width: 220.0,
notelist_width: 280.0, notelist_width: 280.0,
outline_width: default_outline_width(),
sidebar_collapsed: false, sidebar_collapsed: false,
notelist_collapsed: false, notelist_collapsed: false,
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
+47 -59
View File
@@ -52,11 +52,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
} }
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> { fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut entries = Vec::new();
let root = Path::new(vault_root); let root = Path::new(vault_root);
let Ok(read_dir) = fs::read_dir(dir) else { let Ok(read_dir) = fs::read_dir(dir) else {
return entries; return Vec::new();
}; };
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count) // Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
@@ -76,29 +75,29 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.cmp(&b.file_name().to_string_lossy().to_lowercase()) .cmp(&b.file_name().to_string_lossy().to_lowercase())
}); });
// Store note_count for the parent directory (used when this is the top-level call) // Scan sibling notebooks in parallel: each subtree is independent, so
// Each directory pushes its own entry with its own note_count // overlapping the per-directory read_dir calls hides FUSE latency on Android.
for dir_entry in dirs { // par_iter().collect() preserves the (already-sorted) order.
let path = dir_entry.path(); let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
let name = dir_entry.file_name().to_string_lossy().to_string(); paths
.par_iter()
.map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(&path) .unwrap_or(path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
let (children, child_note_count) = scan_dir_with_count(&path, vault_root); NotebookEntry {
entries.push(NotebookEntry {
name, name,
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
children, children,
note_count: child_note_count, note_count: child_note_count,
});
} }
})
entries .collect()
} }
/// Combined scan: returns (children, note_count) in a single read_dir pass. /// Combined scan: returns (children, note_count) in a single read_dir pass.
@@ -130,26 +129,26 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
.cmp(&b.file_name().to_string_lossy().to_lowercase()) .cmp(&b.file_name().to_string_lossy().to_lowercase())
}); });
let mut entries = Vec::new(); let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
for dir_entry in subdirs { let entries: Vec<NotebookEntry> = paths
let path = dir_entry.path(); .par_iter()
let name = dir_entry.file_name().to_string_lossy().to_string(); .map(|path| {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let relative = path let relative = path
.strip_prefix(root) .strip_prefix(root)
.unwrap_or(&path) .unwrap_or(path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
let (children, child_note_count) = scan_dir_with_count(&path, vault_root); NotebookEntry {
entries.push(NotebookEntry {
name, name,
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
children, children,
note_count: child_note_count, note_count: child_note_count,
});
} }
})
.collect();
(entries, note_count) (entries, note_count)
} }
@@ -197,47 +196,36 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS // On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)] #[cfg(mobile)]
{ {
let mut notes = Vec::new(); // Collect candidate .md paths first, then read their metadata in parallel.
// Log what read_dir sees // Overlapping the per-file reads hides FUSE latency on Android (matches the
if let Ok(rd) = fs::read_dir(root) { // desktop path below). The previous version also did an extra read_dir purely
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect(); // for debug logging, which doubled the directory I/O on the sandboxed FS.
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root); let paths: Vec<PathBuf> = if notebook_path.is_some() {
for (i, entry) in items.iter().enumerate().take(5) { match fs::read_dir(root) {
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err"); Ok(rd) => rd
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft); .filter_map(|e| e.ok())
} .map(|e| e.path())
} else { .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
log::info!("scan_notes: read_dir FAILED for {:?}", root); .collect(),
} Err(_) => Vec::new(),
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 { } else {
let hn_dir = helixnotes_dir(vault_path); let hn_dir = helixnotes_dir(vault_path);
for entry in WalkDir::new(root) WalkDir::new(root)
.into_iter() .into_iter()
// filter_entry skips descending into hidden dirs (unlike filter) // filter_entry skips descending into hidden dirs (unlike filter)
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir)) .filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .map(|e| e.path().to_path_buf())
e.file_type().is_file() .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
&& e.path().extension().and_then(|x| x.to_str()) == Some("md") .collect()
}) };
{
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) { let mut notes: Vec<NoteEntry> = paths
notes.push(note); .par_iter()
} .filter_map(|path| read_note_entry_metadata_only(path, vault_root).ok())
} .collect();
}
log::info!("scan_notes: mobile scan found {} notes", notes.len()); log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes); return Ok(notes);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.3.2", "version": "1.3.3",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+29
View File
@@ -173,6 +173,21 @@
--danger-hover: #d43d3d; --danger-hover: #d43d3d;
--success: #40c057; --success: #40c057;
--warning: #fab005; --warning: #fab005;
/* Obsidian-style callout accent hues (RGB triplets; mixed with the background via opacity, so one set works on every theme) */
--callout-note: 68, 138, 255;
--callout-abstract: 0, 191, 188;
--callout-info: 0, 184, 212;
--callout-todo: 0, 184, 212;
--callout-tip: 0, 191, 188;
--callout-success: 8, 185, 78;
--callout-question: 236, 191, 0;
--callout-warning: 236, 117, 0;
--callout-failure: 233, 49, 71;
--callout-danger: 233, 49, 71;
--callout-bug: 233, 49, 71;
--callout-example: 120, 82, 238;
--callout-quote: 158, 158, 158;
--callout-custom: 124, 134, 156;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
@@ -204,6 +219,20 @@
--danger-hover: #eba0ac; --danger-hover: #eba0ac;
--success: #a6e3a1; --success: #a6e3a1;
--warning: #f9e2af; --warning: #f9e2af;
--callout-note: 100, 160, 255;
--callout-abstract: 60, 209, 205;
--callout-info: 60, 200, 224;
--callout-todo: 60, 200, 224;
--callout-tip: 60, 209, 205;
--callout-success: 80, 205, 120;
--callout-question: 240, 205, 80;
--callout-warning: 245, 150, 60;
--callout-failure: 240, 95, 115;
--callout-danger: 240, 95, 115;
--callout-bug: 240, 95, 115;
--callout-example: 165, 135, 245;
--callout-quote: 150, 150, 162;
--callout-custom: 150, 160, 182;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
+17
View File
@@ -1,6 +1,7 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import type { import type {
AppConfig, AppConfig,
CustomTheme,
NoteContent, NoteContent,
NoteEntry, NoteEntry,
NoteMeta, NoteMeta,
@@ -36,6 +37,22 @@ export async function setAccentColor(color: string): Promise<void> {
return invoke("set_accent_color", { color }); return invoke("set_accent_color", { color });
} }
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
return invoke("save_custom_theme", { theme });
}
export async function deleteCustomTheme(id: string): Promise<void> {
return invoke("delete_custom_theme", { id });
}
export async function exportCustomTheme(id: string, path: string): Promise<void> {
return invoke("export_custom_theme", { id, path });
}
export async function importCustomThemes(path: string): Promise<CustomTheme[]> {
return invoke("import_custom_themes", { path });
}
export async function setFontSize(size: number): Promise<void> { export async function setFontSize(size: number): Promise<void> {
return invoke("set_font_size", { size }); return invoke("set_font_size", { size });
} }
+36 -13
View File
@@ -14,6 +14,7 @@
import { import {
sidebarWidth, sidebarWidth,
notelistWidth, notelistWidth,
outlineWidth,
sidebarCollapsed, sidebarCollapsed,
notelistCollapsed, notelistCollapsed,
collapsedNotebooks, collapsedNotebooks,
@@ -38,6 +39,7 @@
rootNoteCount, rootNoteCount,
viewMode, viewMode,
sortMode, sortMode,
groupNotesByDate,
tasksLayout, tasksLayout,
tasksHideCompleted, tasksHideCompleted,
tasksOnlyFlagged, tasksOnlyFlagged,
@@ -51,16 +53,16 @@
notebookOrder, notebookOrder,
noteOrder, noteOrder,
syncState, syncState,
platformIsMobile platformIsMobile,
activeVaultConfig,
} from '$lib/stores/app'; } from '$lib/stores/app';
import { keybindings, matchAction } from '$lib/keybindings'; import { keybindings, matchAction } from '$lib/keybindings';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { darkThemes } from '$lib/platform'; import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -120,16 +122,16 @@
} }
function syncConfigured(): boolean { function syncConfigured(): boolean {
return get(appConfig)?.sync_provider === 'webdav'; return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
} }
// Auto-sync on a timer (only when configured and an interval is set). // Auto-sync on a timer (only when configured and an interval is set).
async function checkScheduledSync() { async function checkScheduledSync() {
const config = get(appConfig); const vc = activeVaultConfig(get(appConfig));
if (config?.sync_provider !== 'webdav') return; if (vc?.sync_provider !== 'webdav') return;
const mins = config.sync_interval_minutes ?? 0; const mins = vc.sync_interval_minutes ?? 0;
if (!mins || get(syncState).running) return; if (!mins || get(syncState).running) return;
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0; const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
if (Date.now() - last >= mins * 60 * 1000) { if (Date.now() - last >= mins * 60 * 1000) {
try { await syncNow(); } catch (_) {} try { await syncNow(); } catch (_) {}
} }
@@ -202,6 +204,7 @@
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
outline_width: $outlineWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed, notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
@@ -209,6 +212,7 @@
notebook_order: $notebookOrder, notebook_order: $notebookOrder,
note_order: $noteOrder, note_order: $noteOrder,
sort_mode: $sortMode, sort_mode: $sortMode,
group_notes_by_date: $groupNotesByDate,
last_view_mode: $viewMode, last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null, last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag, last_tag: $activeTag,
@@ -426,6 +430,13 @@
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
e.preventDefault();
noteList?.trashActiveNote();
return;
}
const action = matchAction(e, $keybindings); const action = matchAction(e, $keybindings);
if (action) { if (action) {
e.preventDefault(); e.preventDefault();
@@ -528,6 +539,7 @@
$effect(() => { $effect(() => {
$sidebarCollapsed; $sidebarCollapsed;
$notelistCollapsed; $notelistCollapsed;
$outlineWidth;
persistState(); persistState();
}); });
@@ -544,6 +556,7 @@
$effect(() => { $effect(() => {
$sortMode; $sortMode;
$groupNotesByDate;
$tasksLayout; $tasksLayout;
$tasksHideCompleted; $tasksHideCompleted;
$tasksOnlyFlagged; $tasksOnlyFlagged;
@@ -567,6 +580,7 @@
const state = await loadVaultState(); const state = await loadVaultState();
$sidebarWidth = state.sidebar_width; $sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width; $notelistWidth = state.notelist_width;
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
$sidebarCollapsed = state.sidebar_collapsed; $sidebarCollapsed = state.sidebar_collapsed;
$notelistCollapsed = state.notelist_collapsed ?? false; $notelistCollapsed = state.notelist_collapsed ?? false;
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
@@ -574,6 +588,7 @@
$notebookOrder = state.notebook_order ?? {}; $notebookOrder = state.notebook_order ?? {};
$noteOrder = state.note_order ?? {}; $noteOrder = state.note_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode; if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout; if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed; if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged; if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
@@ -697,12 +712,13 @@
unlistenSync.push(await listen('sync-done', (event: any) => { unlistenSync.push(await listen('sync-done', (event: any) => {
syncState.set({ running: false, error: null }); syncState.set({ running: false, error: null });
const cur = get(appConfig); const cur = get(appConfig);
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time }); const ts = event.payload?.last_sync_time;
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
})); }));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' }))); unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
// Sync when the vault opens (if enabled) // Sync when the vault opens (if enabled)
if (syncConfigured() && get(appConfig)?.sync_on_open) { if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
try { await syncNow(); } catch (_) {} try { await syncNow(); } catch (_) {}
} }
@@ -712,8 +728,8 @@
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync. // Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
unsubDirty = editorDirty.subscribe((d) => { unsubDirty = editorDirty.subscribe((d) => {
const config = get(appConfig); const vc = activeVaultConfig(get(appConfig));
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) { if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer); if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000); onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
} }
@@ -829,7 +845,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
{#if $appConfig?.sync_provider === 'webdav'} {#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}> <button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/> <path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
@@ -856,6 +872,13 @@
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</button> </button>
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings"> <button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -2,14 +2,13 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { getGraphData } from '$lib/api'; import { getGraphData } from '$lib/api';
import { activeNotePath, appConfig } from '$lib/stores/app'; import { activeNotePath, appConfig } from '$lib/stores/app';
import { isMobile } from '$lib/platform';
let { onclose, onnavigate }: { let { onclose, onnavigate }: {
onclose: () => void; onclose: () => void;
onnavigate: (path: string, title: string) => void; onnavigate: (path: string, title: string) => void;
} = $props(); } = $props();
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let canvas = $state<HTMLCanvasElement>(null!); let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true); let loading = $state(true);
let searchQuery = $state(''); let searchQuery = $state('');
+2 -2
View File
@@ -15,9 +15,9 @@
bindingsEqual, bindingsEqual,
type ActionDef, type ActionDef,
} from '$lib/keybindings'; } from '$lib/keybindings';
import { isMobile } from '$lib/platform';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Customizable shortcuts, grouped for display. // Customizable shortcuts, grouped for display.
const actionGroups: { title: string; actions: ActionDef[] }[] = [ const actionGroups: { title: string; actions: ActionDef[] }[] = [
@@ -96,7 +96,7 @@
function close() { function close() {
$showInfo = false; $showInfo = false;
activeTab = 'shortcuts'; activeTab = isMobile ? 'about' : 'shortcuts';
} }
function openLink(url: string) { function openLink(url: string) {
+75 -7
View File
@@ -9,6 +9,7 @@
activeNotebook, activeNotebook,
activeTag, activeTag,
sortMode, sortMode,
groupNotesByDate,
editorDirty, editorDirty,
quickAccessPaths, quickAccessPaths,
appConfig, appConfig,
@@ -37,12 +38,13 @@
getAllTags, getAllTags,
createDailyNote createDailyNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime, formatDate } from '$lib/utils/time'; import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte'; import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
@@ -55,8 +57,6 @@
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false); let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]); let trashNotebooks = $state<TrashNotebookEntry[]>([]);
let trashBusy = $state<string | null>(null); let trashBusy = $state<string | null>(null);
@@ -213,6 +213,24 @@
const customOrderViews = new Set(['all', 'notebook', 'tag']); const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode)); let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
let noteGroups = $derived.by(() => {
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
const pinned: NoteEntry[] = [];
const rest: NoteEntry[] = [];
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
const groups: { label: string; notes: NoteEntry[] }[] = [];
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
let cur: { label: string; notes: NoteEntry[] } | null = null;
for (const n of rest) {
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
cur.notes.push(n);
}
return groups;
});
// Virtual scroll // Virtual scroll
let listContainer = $state<HTMLDivElement>(null!); let listContainer = $state<HTMLDivElement>(null!);
let scrollTop = $state(0); let scrollTop = $state(0);
@@ -431,6 +449,14 @@
} }
} }
function handleListDoubleClick(e: MouseEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
e.stopPropagation();
if ($viewMode === 'tasks' || $viewMode === 'trash') return;
handleCreateNote();
}
async function handleRename(note: NoteEntry) { async function handleRename(note: NoteEntry) {
if (!editValue.trim() || editValue.trim() === note.meta.title) { if (!editValue.trim() || editValue.trim() === note.meta.title) {
editingNote = null; editingNote = null;
@@ -474,6 +500,13 @@
} }
} }
// Move the currently open note to the trash (keyboard shortcut from the editor).
export async function trashActiveNote() {
if ($viewMode === 'trash' || !$activeNotePath) return;
const note = $notes.find(n => n.path === $activeNotePath);
if (note) await handleDelete(note);
}
async function handleRestore(note: NoteEntry) { async function handleRestore(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -949,6 +982,13 @@
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> <line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
</svg> </svg>
</button> </button>
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
{/if}
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
@@ -1047,7 +1087,8 @@
</div> </div>
{/if} {/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
{#if $viewMode === 'tasks'} {#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} /> <TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if} {/if}
@@ -1127,9 +1168,7 @@
{/each} {/each}
{/if} {/if}
<div style="height: {topPad}px"></div> {#snippet noteRow(note: NoteEntry, noteIndex: number)}
{#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i}
{#if editingNote === note.path} {#if editingNote === note.path}
<div class="note-item active"> <div class="note-item active">
<input <input
@@ -1283,8 +1322,22 @@
{/if} {/if}
</button> </button>
{/if} {/if}
{/snippet}
{#if groupingActive}
{#each noteGroups as group (group.label)}
<div class="note-list-section">{group.label}</div>
{#each group.notes as note (note.path)}
{@render noteRow(note, -1)}
{/each}
{/each}
{:else}
<div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)}
{@render noteRow(note, startIndex + i)}
{/each} {/each}
<div style="height: {bottomPad}px"></div> <div style="height: {bottomPad}px"></div>
{/if}
</div> </div>
</div> </div>
@@ -1594,6 +1647,21 @@
background: var(--text-secondary); background: var(--text-secondary);
} }
.note-list-section {
padding: 14px 12px 4px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--accent);
border-bottom: 1px solid var(--border-light);
margin-bottom: 2px;
user-select: none;
}
.note-list-section:first-child {
padding-top: 6px;
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
+3
View File
@@ -103,8 +103,11 @@
if (!$editorDirty) { if (!$editorDirty) {
try { try {
const content = await readNote(notePath); const content = await readNote(notePath);
// Ignore the file-watcher echo of our own save; only reload genuine external edits.
if (content.content.trim() !== (editor?.getCurrentBody() ?? '').trim()) {
$activeNote = content; $activeNote = content;
editor?.loadNote(notePath, content.content); editor?.loadNote(notePath, content.content);
}
} catch (_) {} } catch (_) {}
} }
} }
+1 -2
View File
@@ -3,6 +3,7 @@
import { searchNotes, readNote } from '$lib/api'; import { searchNotes, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import type { SearchResult } from '$lib/types'; import type { SearchResult } from '$lib/types';
import { isMobile } from '$lib/platform';
let query = $state(''); let query = $state('');
let results = $state<SearchResult[]>([]); let results = $state<SearchResult[]>([]);
@@ -83,8 +84,6 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
async function openResult(result: SearchResult) { async function openResult(result: SearchResult) {
try { try {
const content = await readNote(result.path); const content = await readNote(result.path);
+734 -21
View File
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app'; import { showSettings, theme, appConfig, activeVaultConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall, customThemes } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setContentWidth, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow, saveCustomTheme, deleteCustomTheme, exportCustomTheme, importCustomThemes } from '$lib/api';
import { darkThemes } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog, save as saveDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry, CustomTheme, CustomThemeColors } from '$lib/types';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates'; type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
@@ -303,25 +302,45 @@
} }
// ── WebDAV sync ── // ── WebDAV sync ──
let syncProvider = $state<string | null>($appConfig?.sync_provider ?? null); let syncProvider = $state<string | null>(activeVaultConfig($appConfig)?.sync_provider ?? null);
let syncUrl = $state($appConfig?.webdav_url ?? ''); let syncUrl = $state(activeVaultConfig($appConfig)?.webdav_url ?? '');
let syncUsername = $state($appConfig?.webdav_username ?? ''); let syncUsername = $state(activeVaultConfig($appConfig)?.webdav_username ?? '');
let syncPassword = $state($appConfig?.webdav_password ?? ''); let syncPassword = $state(activeVaultConfig($appConfig)?.webdav_password ?? '');
let syncShowPassword = $state(false); let syncShowPassword = $state(false);
let syncTestLoading = $state(false); let syncTestLoading = $state(false);
let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncRunning = $state(false); let syncRunning = $state(false);
let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncOnOpen = $state($appConfig?.sync_on_open ?? false); let syncOnOpen = $state(activeVaultConfig($appConfig)?.sync_on_open ?? false);
let syncOnChange = $state($appConfig?.sync_on_change ?? false); let syncOnChange = $state(activeVaultConfig($appConfig)?.sync_on_change ?? false);
let syncIntervalMinutes = $state($appConfig?.sync_interval_minutes ?? 0); let syncIntervalMinutes = $state(activeVaultConfig($appConfig)?.sync_interval_minutes ?? 0);
// Re-seed the sync form when the active vault changes, so Settings reflects the current vault.
let lastSyncVault: string | null = null;
$effect(() => {
const path = $appConfig?.active_vault ?? null;
if (path === lastSyncVault) return;
lastSyncVault = path;
const vc = activeVaultConfig($appConfig);
syncProvider = vc?.sync_provider ?? null;
syncUrl = vc?.webdav_url ?? '';
syncUsername = vc?.webdav_username ?? '';
syncPassword = vc?.webdav_password ?? '';
syncOnOpen = vc?.sync_on_open ?? false;
syncOnChange = vc?.sync_on_change ?? false;
syncIntervalMinutes = vc?.sync_interval_minutes ?? 0;
});
async function saveSyncSettings() { async function saveSyncSettings() {
await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes); await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes);
// Reflect into the global store so the top-bar button visibility and the auto-sync // Reflect into the active vault's config in the store so the top-bar button and auto-sync
// triggers update live, without needing an app restart. // triggers update live, without an app restart.
if ($appConfig) $appConfig = { if ($appConfig) {
...$appConfig, const cur = $appConfig;
$appConfig = {
...cur,
vaults: cur.vaults.map((v) => v.path === cur.active_vault ? {
...v,
sync_provider: syncProvider, sync_provider: syncProvider,
webdav_url: syncUrl || null, webdav_url: syncUrl || null,
webdav_username: syncUsername || null, webdav_username: syncUsername || null,
@@ -329,8 +348,10 @@
sync_on_open: syncOnOpen, sync_on_open: syncOnOpen,
sync_on_change: syncOnChange, sync_on_change: syncOnChange,
sync_interval_minutes: syncIntervalMinutes, sync_interval_minutes: syncIntervalMinutes,
} : v),
}; };
} }
}
async function handleTestSync() { async function handleTestSync() {
syncTestLoading = true; syncTestLoading = true;
@@ -368,7 +389,11 @@
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`); if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' }; syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
syncRunning = false; syncRunning = false;
if ($appConfig && event.payload.last_sync_time) $appConfig = { ...$appConfig, last_sync_time: event.payload.last_sync_time }; if ($appConfig && event.payload.last_sync_time) {
const cur = $appConfig;
const ts = event.payload.last_sync_time;
$appConfig = { ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) };
}
cleanup(); cleanup();
})); }));
unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => { unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => {
@@ -387,7 +412,8 @@
} }
let isThemeDark = $derived( let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
($theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false))
); );
const themePresets = [ const themePresets = [
@@ -494,6 +520,7 @@
let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null); let activeContentWidth = $state<number | null>($appConfig?.content_width ?? null);
let themeDropdownOpen = $state(false); let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false); let accentDropdownOpen = $state(false);
let activeCustomTheme = $derived($customThemes.find(c => c.id === $theme) ?? null);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]); let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]); let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
let isCustomAccent = $derived(activeAccent.startsWith('#')); let isCustomAccent = $derived(activeAccent.startsWith('#'));
@@ -501,6 +528,171 @@
isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light) isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light)
); );
// Custom theme editor state
const DEFAULT_DARK_COLORS: CustomThemeColors = {
bg_primary: '#1a1b26', bg_secondary: '#1f2029', bg_tertiary: '#2a2b3d',
bg_hover: '#2f3145', bg_active: '#373a52', bg_editor: '#1a1b26',
text_primary: '#c8cde4', text_secondary: '#6b7094', border_color: '#2f3145',
};
const DEFAULT_LIGHT_COLORS: CustomThemeColors = {
bg_primary: '#ffffff', bg_secondary: '#f8f9fa', bg_tertiary: '#f0f2f5',
bg_hover: '#e8eaed', bg_active: '#dde1e7', bg_editor: '#ffffff',
text_primary: '#1a1a2e', text_secondary: '#6b7280', border_color: '#e2e5ea',
};
let customThemeEditing = $state<CustomTheme | null>(null);
let customThemeEditorOpen = $state(false);
let customThemeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let customThemeExportingId = $state<string | null>(null);
let customThemeImporting = $state(false);
function openNewCustomThemeEditor() {
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) || $theme.startsWith('custom-') && ($customThemes.find(c => c.id === $theme)?.is_dark ?? false);
customThemeEditing = {
id: `custom-${Date.now()}`,
name: '',
is_dark: isDark,
colors: isDark ? { ...DEFAULT_DARK_COLORS } : { ...DEFAULT_LIGHT_COLORS },
};
customThemeEditorOpen = true;
}
function openEditCustomThemeEditor(ct: CustomTheme) {
customThemeEditing = { ...ct, colors: { ...ct.colors } };
customThemeEditorOpen = true;
}
function closeCustomThemeEditor() {
customThemeEditorOpen = false;
customThemeEditing = null;
}
function previewCustomTheme() {
if (!customThemeEditing) return;
const root = document.documentElement;
root.style.setProperty('--bg-primary', customThemeEditing.colors.bg_primary);
root.style.setProperty('--bg-secondary', customThemeEditing.colors.bg_secondary);
root.style.setProperty('--bg-tertiary', customThemeEditing.colors.bg_tertiary);
root.style.setProperty('--bg-hover', customThemeEditing.colors.bg_hover);
root.style.setProperty('--bg-active', customThemeEditing.colors.bg_active);
root.style.setProperty('--bg-editor', customThemeEditing.colors.bg_editor);
root.style.setProperty('--text-primary', customThemeEditing.colors.text_primary);
root.style.setProperty('--text-secondary', customThemeEditing.colors.text_secondary);
root.style.setProperty('--border-color', customThemeEditing.colors.border_color);
root.classList.toggle('dark', customThemeEditing.is_dark);
}
function restoreCurrentTheme() {
// Re-apply the currently active theme to undo preview
const root = document.documentElement;
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
for (const v of varsToClear) root.style.removeProperty(v);
const namedThemes = ['solarized-light','solarized-dark','catppuccin','nord','tokyo-night','github-light','github-dark','dracula','blueberry','forest-green','gruvbox','midnight-tide','cherry-blossom','synthwave','ember','moonlit','light-coffee','dark-coffee','cotton-candy','crimson','cloud','peach','material-dark','material-light','monokai','rose-pine','everforest','horizon','cyberpunk','black','one-dark'];
if ($theme.startsWith('custom-')) {
const ct = $customThemes.find(c => c.id === $theme);
if (ct) {
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
root.style.setProperty('--bg-tertiary', ct.colors.bg_tertiary);
root.style.setProperty('--bg-hover', ct.colors.bg_hover);
root.style.setProperty('--bg-active', ct.colors.bg_active);
root.style.setProperty('--bg-editor', ct.colors.bg_editor);
root.style.setProperty('--text-primary', ct.colors.text_primary);
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
root.style.setProperty('--border-color', ct.colors.border_color);
if (ct.is_dark) root.classList.add('dark');
}
} else if (namedThemes.includes($theme)) {
root.setAttribute('data-theme', $theme);
if (darkThemes.includes($theme)) root.classList.add('dark');
} else if ($theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
async function saveCustomThemeEditor() {
if (!customThemeEditing || !customThemeEditing.name.trim()) return;
const theme_to_save = { ...customThemeEditing, name: customThemeEditing.name.trim() };
try {
await saveCustomTheme(theme_to_save);
if ($appConfig) {
const idx = $appConfig.custom_themes.findIndex(t => t.id === theme_to_save.id);
if (idx >= 0) $appConfig.custom_themes[idx] = theme_to_save;
else $appConfig.custom_themes = [...$appConfig.custom_themes, theme_to_save];
$appConfig = { ...$appConfig };
}
closeCustomThemeEditor();
} catch (e) {
customThemeMessage = { type: 'error', text: String(e) };
}
}
function cancelCustomThemeEditor() {
restoreCurrentTheme();
closeCustomThemeEditor();
}
async function selectCustomTheme(ct: CustomTheme) {
$theme = ct.id;
setTheme(ct.id);
}
async function removeCustomTheme(ct: CustomTheme) {
try {
await deleteCustomTheme(ct.id);
if ($appConfig) {
$appConfig.custom_themes = $appConfig.custom_themes.filter(t => t.id !== ct.id);
$appConfig = { ...$appConfig };
}
if ($theme === ct.id) {
$theme = 'system';
}
} catch (e) {
customThemeMessage = { type: 'error', text: String(e) };
}
}
async function handleExportCustomTheme(ct: CustomTheme) {
customThemeExportingId = ct.id;
try {
const safeName = ct.name.replace(/[^a-z0-9]/gi, '-').toLowerCase();
const path = await saveDialog({ filters: [{ name: 'JSON', extensions: ['json'] }], defaultPath: `${safeName}.json` });
if (path) {
await exportCustomTheme(ct.id, path);
customThemeMessage = { type: 'success', text: `"${ct.name}" exported.` };
}
} catch (e) {
customThemeMessage = { type: 'error', text: String(e) };
} finally {
customThemeExportingId = null;
}
}
async function handleImportCustomThemes() {
customThemeImporting = true;
try {
const path = await openDialog({ filters: [{ name: 'JSON', extensions: ['json'] }], multiple: false });
if (path) {
const imported = await importCustomThemes(path as string);
if ($appConfig) {
for (const t of imported) {
const idx = $appConfig.custom_themes.findIndex(x => x.id === t.id);
if (idx >= 0) $appConfig.custom_themes[idx] = t;
else $appConfig.custom_themes.push(t);
}
$appConfig = { ...$appConfig };
}
customThemeMessage = { type: 'success', text: `Imported ${imported.length} theme${imported.length !== 1 ? 's' : ''}.` };
}
} catch (e) {
customThemeMessage = { type: 'error', text: String(e) };
} finally {
customThemeImporting = false;
}
}
// General settings // General settings
let compactNotes = $state($appConfig?.compact_notes ?? false); let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true); let showNoteDates = $state($appConfig?.show_note_dates ?? true);
@@ -863,12 +1055,14 @@
</svg> </svg>
Sync Sync
</button> </button>
{#if !isAndroid}
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}> <button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/> <path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
</svg> </svg>
Updates Updates
</button> </button>
{/if}
</nav> </nav>
<div class="settings-body"> <div class="settings-body">
@@ -1230,6 +1424,166 @@
</div> </div>
</div> </div>
<!-- Custom Themes -->
<div class="settings-section">
<div class="section-header-row">
<h3>Custom Themes</h3>
<button class="create-theme-btn" onclick={openNewCustomThemeEditor}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Theme
</button>
</div>
{#if $customThemes.length > 0}
<div class="custom-theme-list">
{#each $customThemes as ct}
<div class="custom-theme-row" class:active={$theme === ct.id}>
<button class="custom-theme-swatch-btn" onclick={() => selectCustomTheme(ct)} title="Apply {ct.name}">
<span class="custom-theme-swatch" style="background: {ct.colors.bg_primary}; border-color: {ct.colors.border_color}">
<span class="swatch-sidebar" style="background: {ct.colors.bg_secondary}"></span>
</span>
<span class="custom-theme-name">{ct.name}</span>
{#if $theme === ct.id}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
<div class="custom-theme-actions">
<button class="icon-btn" title="Edit" onclick={() => openEditCustomThemeEditor(ct)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 00-3.986-3.987L3.842 16.174a2 2 0 00-.5.83l-1.321 4.352a.5.5 0 00.623.622l4.353-1.32a2 2 0 00.83-.497z"/></svg>
</button>
{#if !isMobile}
<button class="icon-btn" title="Export" disabled={customThemeExportingId === ct.id} onclick={() => handleExportCustomTheme(ct)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
{/if}
<button class="icon-btn danger" title="Delete" onclick={() => removeCustomTheme(ct)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="setting-hint" style="margin-top: 8px;">No custom themes yet. Create one to define your own colors.</p>
{/if}
{#if !isMobile}
<div class="custom-theme-io">
<button class="backup-link-btn" onclick={handleImportCustomThemes} disabled={customThemeImporting}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{customThemeImporting ? 'Importing...' : 'Import theme'}
</button>
</div>
{/if}
{#if customThemeMessage}
<div class="import-result {customThemeMessage.type}" style="margin-top: 10px;">
{#if customThemeMessage.type === 'success'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
{/if}
<span>{customThemeMessage.text}</span>
</div>
{/if}
</div>
<!-- Custom Theme Editor Modal -->
{#if customThemeEditorOpen && customThemeEditing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal-overlay" onclick={cancelCustomThemeEditor} onkeydown={(e) => e.key === 'Escape' && cancelCustomThemeEditor()}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="custom-theme-modal" onclick={(e) => e.stopPropagation()}>
<div class="custom-theme-modal-header">
<h3>{customThemeEditing.id.startsWith('custom-') && $customThemes.some(c => c.id === customThemeEditing!.id) ? 'Edit Theme' : 'New Custom Theme'}</h3>
<button class="close-btn" onclick={cancelCustomThemeEditor}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="custom-theme-modal-body">
<div class="ct-field">
<label class="ct-label">Theme Name</label>
<input class="ct-name-input" type="text" placeholder="My Theme" bind:value={customThemeEditing.name} maxlength={40} />
</div>
<div class="ct-field">
<label class="ct-label">Mode</label>
<div class="setting-options">
<button class="option-btn" class:active={!customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = false; previewCustomTheme(); } }}>Light</button>
<button class="option-btn" class:active={customThemeEditing.is_dark} onclick={() => { if (customThemeEditing) { customThemeEditing.is_dark = true; previewCustomTheme(); } }}>Dark</button>
</div>
</div>
<div class="ct-colors-grid">
<div class="ct-color-group">
<span class="ct-group-label">Backgrounds</span>
{#each [
{ key: 'bg_primary', label: 'Primary' },
{ key: 'bg_secondary', label: 'Sidebar' },
{ key: 'bg_tertiary', label: 'Cards' },
{ key: 'bg_editor', label: 'Editor' },
{ key: 'bg_hover', label: 'Hover' },
{ key: 'bg_active', label: 'Active' },
] as field}
<label class="ct-color-row">
<input
type="color"
class="ct-color-input"
value={customThemeEditing.colors[field.key as keyof CustomThemeColors]}
oninput={(e) => { if (customThemeEditing) { (customThemeEditing.colors as any)[field.key] = e.currentTarget.value; previewCustomTheme(); } }}
/>
<span class="ct-color-label">{field.label}</span>
</label>
{/each}
</div>
<div class="ct-color-group">
<span class="ct-group-label">Text &amp; Borders</span>
{#each [
{ key: 'text_primary', label: 'Primary Text' },
{ key: 'text_secondary', label: 'Secondary Text' },
{ key: 'border_color', label: 'Borders' },
] as field}
<label class="ct-color-row">
<input
type="color"
class="ct-color-input"
value={customThemeEditing.colors[field.key as keyof CustomThemeColors]}
oninput={(e) => { if (customThemeEditing) { (customThemeEditing.colors as any)[field.key] = e.currentTarget.value; previewCustomTheme(); } }}
/>
<span class="ct-color-label">{field.label}</span>
</label>
{/each}
</div>
</div>
<div class="ct-preview">
<span class="ct-preview-label">Preview</span>
<div class="ct-preview-window" style="--p-bg: {customThemeEditing.colors.bg_primary}; --p-sidebar: {customThemeEditing.colors.bg_secondary}; --p-card: {customThemeEditing.colors.bg_tertiary}; --p-text: {customThemeEditing.colors.text_primary}; --p-text2: {customThemeEditing.colors.text_secondary}; --p-border: {customThemeEditing.colors.border_color}; --p-hover: {customThemeEditing.colors.bg_hover};">
<div class="ct-preview-sidebar">
<div class="ct-preview-item active"></div>
<div class="ct-preview-item"></div>
<div class="ct-preview-item"></div>
</div>
<div class="ct-preview-main">
<div class="ct-preview-title"></div>
<div class="ct-preview-line long"></div>
<div class="ct-preview-line medium"></div>
<div class="ct-preview-line short"></div>
</div>
</div>
</div>
</div>
<div class="custom-theme-modal-footer">
<button class="cancel-btn" onclick={cancelCustomThemeEditor}>Cancel</button>
<button class="save-btn" onclick={saveCustomThemeEditor} disabled={!customThemeEditing.name.trim()}>Save Theme</button>
</div>
</div>
</div>
{/if}
<div class="settings-section"> <div class="settings-section">
<h3>Font Size</h3> <h3>Font Size</h3>
<div class="font-size-options"> <div class="font-size-options">
@@ -1760,8 +2114,8 @@
<span>{syncMessage.text}</span> <span>{syncMessage.text}</span>
</div> </div>
{/if} {/if}
{#if $appConfig?.last_sync_time} {#if activeVaultConfig($appConfig)?.last_sync_time}
<p class="setting-hint">Last sync: {new Date($appConfig.last_sync_time).toLocaleString()}</p> <p class="setting-hint">Last sync: {new Date(activeVaultConfig($appConfig)!.last_sync_time!).toLocaleString()}</p>
{/if} {/if}
<p class="setting-hint">If a note is edited on two devices, both are kept (one as a "(conflict)" copy).</p> <p class="setting-hint">If a note is edited on two devices, both are kept (one as a "(conflict)" copy).</p>
</div> </div>
@@ -2964,4 +3318,363 @@
padding: 12px 6px; padding: 12px 6px;
} }
/* ── Custom Themes ── */
.section-header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-header-row h3 {
margin: 0;
}
.create-theme-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
background: var(--accent-light);
color: var(--text-accent);
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.create-theme-btn:hover {
background: var(--accent);
color: #fff;
}
.custom-theme-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
.custom-theme-row {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: 8px;
border: 1px solid transparent;
padding: 2px 4px 2px 0;
}
.custom-theme-row.active {
background: var(--accent-light);
border-color: var(--accent);
}
.custom-theme-swatch-btn {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
background: none;
border: none;
cursor: pointer;
padding: 5px 6px;
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
text-align: left;
}
.custom-theme-swatch-btn:hover {
background: var(--bg-hover);
}
.custom-theme-swatch {
width: 32px;
height: 22px;
border-radius: 5px;
border: 1px solid;
display: flex;
overflow: hidden;
flex-shrink: 0;
}
.swatch-sidebar {
width: 35%;
height: 100%;
}
.custom-theme-name {
flex: 1;
}
.custom-theme-actions {
display: flex;
gap: 2px;
}
.icon-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 5px;
border-radius: 5px;
display: flex;
align-items: center;
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.icon-btn.danger:hover {
background: rgba(220, 38, 38, 0.1);
color: var(--danger);
}
.custom-theme-io {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
/* ── Custom Theme Modal ── */
.custom-theme-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 3000;
}
.custom-theme-modal {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 14px;
box-shadow: var(--shadow-lg);
width: 480px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.custom-theme-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 14px;
border-bottom: 1px solid var(--border-light);
}
.custom-theme-modal-header h3 {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.custom-theme-modal-body {
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.custom-theme-modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 14px 20px;
border-top: 1px solid var(--border-light);
}
.ct-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct-label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.ct-name-input {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-primary);
font-size: 14px;
padding: 8px 10px;
width: 100%;
box-sizing: border-box;
outline: none;
}
.ct-name-input:focus {
border-color: var(--accent);
}
.ct-colors-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.ct-color-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct-group-label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 2px;
}
.ct-color-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.ct-color-input {
width: 30px;
height: 24px;
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 1px;
cursor: pointer;
background: none;
flex-shrink: 0;
}
.ct-color-label {
font-size: 13px;
color: var(--text-primary);
}
/* Theme preview */
.ct-preview {
display: flex;
flex-direction: column;
gap: 6px;
}
.ct-preview-label {
font-size: 11px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ct-preview-window {
display: flex;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--p-border);
height: 80px;
background: var(--p-bg);
}
.ct-preview-sidebar {
width: 80px;
background: var(--p-sidebar);
border-right: 1px solid var(--p-border);
padding: 8px 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.ct-preview-item {
height: 10px;
border-radius: 3px;
background: var(--p-text2);
opacity: 0.4;
}
.ct-preview-item.active {
background: var(--p-hover);
opacity: 1;
}
.ct-preview-main {
flex: 1;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.ct-preview-title {
height: 10px;
border-radius: 3px;
background: var(--p-text);
width: 60%;
opacity: 0.8;
}
.ct-preview-line {
height: 7px;
border-radius: 3px;
background: var(--p-text2);
opacity: 0.5;
}
.ct-preview-line.long { width: 90%; }
.ct-preview-line.medium { width: 70%; }
.ct-preview-line.short { width: 45%; }
.cancel-btn {
padding: 7px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 7px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
}
.cancel-btn:hover {
background: var(--bg-hover);
}
.save-btn {
padding: 7px 16px;
background: var(--accent);
border: none;
border-radius: 7px;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.save-btn:hover:not(:disabled) {
opacity: 0.9;
}
.save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style> </style>
+136 -46
View File
@@ -26,13 +26,13 @@
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import type { NotebookEntry } from '$lib/types'; import type { NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform';
let { onViewChanged = () => {} }: { let { onViewChanged = () => {} }: {
onViewChanged?: () => void; onViewChanged?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible. // Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush. // When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
@@ -53,6 +53,15 @@
let dropTargetPath = $state<string | null>(null); let dropTargetPath = $state<string | null>(null);
let dropPosition = $state<'above' | 'into' | 'below' | null>(null); let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
let draggedNotebookPath = $state<string | null>(null); let draggedNotebookPath = $state<string | null>(null);
let nbPointer: { src: string } | null = null;
// Notebook paths arrive from Rust with native separators (backslashes on Windows), so path math must be separator-agnostic.
const norm = (p: string) => (p ?? '').replace(/\\/g, '/');
const lastSep = (p: string) => Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
const parentOf = (p: string) => { const i = lastSep(p); return i < 0 ? '' : p.slice(0, i); };
const baseOf = (p: string) => p.slice(lastSep(p) + 1);
const joinPath = (parent: string, name: string) => parent + (parent.includes('\\') ? '\\' : '/') + name;
const isDescendant = (child: string, anc: string) => child.startsWith(anc + '/') || child.startsWith(anc + '\\');
// Apply manual ordering recursively when in 'manual' sort mode. // Apply manual ordering recursively when in 'manual' sort mode.
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged. // In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
@@ -72,12 +81,6 @@
let trashContextMenu = $state<{ x: number; y: number } | null>(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true); let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null); let deleteConfirm = $state<NotebookEntry | null>(null);
function expandNotebook(nb: NotebookEntry) {
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
}
function toggleCollapse(nb: NotebookEntry, e: Event) { function toggleCollapse(nb: NotebookEntry, e: Event) {
e.stopPropagation(); e.stopPropagation();
if ($collapsedNotebooks.includes(nb.path)) { if ($collapsedNotebooks.includes(nb.path)) {
@@ -153,22 +156,11 @@
} }
async function selectNotebook(nb: NotebookEntry) { async function selectNotebook(nb: NotebookEntry) {
const hasVisibleChildren = nb.children.length > 0; // Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
const wasCollapsed = $collapsedNotebooks.includes(nb.path);
// Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (hasVisibleChildren) {
if (wasCollapsed) {
await expandNotebook(nb);
} else {
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
}
return;
}
selectAllNotes(); selectAllNotes();
return; return;
} }
if (hasVisibleChildren) await expandNotebook(nb);
$viewMode = 'notebook'; $viewMode = 'notebook';
$activeNotebook = nb; $activeNotebook = nb;
$activeTag = null; $activeTag = null;
@@ -360,30 +352,34 @@
async function handleNotebookDrop(e: DragEvent, destPath: string) { async function handleNotebookDrop(e: DragEvent, destPath: string) {
e.preventDefault(); e.preventDefault();
dropTargetPath = null;
const srcPath = e.dataTransfer?.getData('text/plain'); const srcPath = e.dataTransfer?.getData('text/plain');
if (!srcPath) return; if (srcPath) await nestNotebook(srcPath, destPath);
}
async function nestNotebook(srcPath: string, destPath: string) {
dropTargetPath = null;
dropPosition = null;
draggedNotebookPath = null; draggedNotebookPath = null;
// Don't move onto self or descendant // Don't move onto self or descendant
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return; if (destPath === srcPath || isDescendant(destPath, srcPath)) return;
// Don't move if already in that parent // Don't move if already in that parent
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/')); const parentDir = parentOf(srcPath);
if (parentDir === destPath) return; if (parentDir === destPath) return;
try { try {
const oldName = srcPath.split('/').pop() || ''; const oldName = baseOf(srcPath);
const newBasePath = destPath + '/' + oldName; const newBasePath = joinPath(destPath, oldName);
await moveNotebook(srcPath, destPath); await moveNotebook(srcPath, destPath);
// Update collapsedNotebooks paths // Update collapsedNotebooks paths
$collapsedNotebooks = $collapsedNotebooks.map(p => { $collapsedNotebooks = $collapsedNotebooks.map(p => {
if (p === srcPath) return newBasePath; if (p === srcPath) return newBasePath;
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length); if (isDescendant(p, srcPath)) return newBasePath + p.slice(srcPath.length);
return p; return p;
}); });
// Update active notebook/note if inside the moved notebook // Update active notebook/note if inside the moved notebook
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) { if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
selectAllNotes(); selectAllNotes();
} }
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) { if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length); const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
$activeNotePath = newNotePath; $activeNotePath = newNotePath;
$activeNote = await readNote(newNotePath); $activeNote = await readNote(newNotePath);
@@ -395,10 +391,74 @@
} }
} }
function nbStopDragListeners() {
window.removeEventListener('pointermove', nbPointerMove);
window.removeEventListener('pointerup', nbDragEnd);
window.removeEventListener('pointercancel', nbDragEnd);
window.removeEventListener('mouseup', nbDragEnd);
}
function nbHandleDown(e: PointerEvent, nb: NotebookEntry) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
nbStopDragListeners();
nbPointer = { src: nb.path };
draggedNotebookPath = nb.path;
// Capture helps touch hold the gesture; on Windows mouse it makes WebView2 swallow the release, so skip it there.
if (e.pointerType === 'touch') {
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch {}
}
window.addEventListener('pointermove', nbPointerMove, { passive: false });
window.addEventListener('pointerup', nbDragEnd);
window.addEventListener('pointercancel', nbDragEnd);
window.addEventListener('mouseup', nbDragEnd);
}
function nbPointerMove(e: PointerEvent) {
if (!nbPointer) return;
e.preventDefault();
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
if (el?.closest('[data-nb-root]')) { dropTargetPath = '__root__'; dropPosition = null; return; }
const row = el?.closest('[data-nb-path]') as HTMLElement | null;
const path = row?.getAttribute('data-nb-path');
if (!path) { dropTargetPath = null; dropPosition = null; return; }
const src = nbPointer.src;
if (path === src || path.startsWith(src + '/')) { dropTargetPath = null; dropPosition = null; return; }
const rect = row!.getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
const pos: 'above' | 'into' | 'below' = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
if (pos === 'into' && src.substring(0, src.lastIndexOf('/')) === path) { dropTargetPath = null; dropPosition = null; return; }
dropTargetPath = path;
dropPosition = pos;
}
function nbDragEnd() {
if (!nbPointer) return;
nbStopDragListeners();
const src = nbPointer.src;
const target = dropTargetPath;
const pos = dropPosition;
nbPointer = null;
draggedNotebookPath = null;
dropTargetPath = null;
dropPosition = null;
if (!target) return;
if (target === '__root__') {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) nestNotebook(src, vaultRoot);
} else if (pos === 'above' || pos === 'below') {
handleNotebookReorder(src, target, pos);
} else if (pos === 'into') {
nestNotebook(src, target);
}
}
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] { function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
if (parentPath === vaultRoot) return tree; const target = norm(parentPath);
if (target === norm(vaultRoot)) return tree;
for (const nb of tree) { for (const nb of tree) {
if (nb.path === parentPath) return nb.children; if (norm(nb.path) === target) return nb.children;
if (nb.children.length > 0) { if (nb.children.length > 0) {
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot); const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
if (found.length > 0) return found; if (found.length > 0) return found;
@@ -412,41 +472,39 @@
dropPosition = null; dropPosition = null;
draggedNotebookPath = null; draggedNotebookPath = null;
if (srcPath === targetPath) return; if (srcPath === targetPath) return;
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant if (isDescendant(targetPath, srcPath)) return; // can't move into descendant
const vaultRoot = $appConfig?.active_vault; const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot) return; if (!vaultRoot) return;
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/')); const srcParent = parentOf(srcPath);
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/')); const targetParent = parentOf(targetPath);
let actualSrcPath = srcPath; let actualSrcPath = srcPath;
try { try {
// Step 1: if different parents, move src into target's parent first // Step 1: if different parents, move src into target's parent first
if (srcParent !== targetParent) { if (srcParent !== targetParent) {
const oldName = srcPath.split('/').pop() || ''; const oldName = baseOf(srcPath);
const newPath = targetParent === vaultRoot const newPath = joinPath(targetParent, oldName);
? vaultRoot + '/' + oldName
: targetParent + '/' + oldName;
await moveNotebook(srcPath, targetParent); await moveNotebook(srcPath, targetParent);
// Migrate paths in collapsedNotebooks // Migrate paths in collapsedNotebooks
$collapsedNotebooks = $collapsedNotebooks.map(p => { $collapsedNotebooks = $collapsedNotebooks.map(p => {
if (p === srcPath) return newPath; if (p === srcPath) return newPath;
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length); if (isDescendant(p, srcPath)) return newPath + p.slice(srcPath.length);
return p; return p;
}); });
// Migrate paths in notebookOrder // Migrate paths in notebookOrder
const migrated: Record<string, number> = {}; const migrated: Record<string, number> = {};
for (const [p, v] of Object.entries($notebookOrder)) { for (const [p, v] of Object.entries($notebookOrder)) {
if (p === srcPath) migrated[newPath] = v; if (p === srcPath) migrated[newPath] = v;
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v; else if (isDescendant(p, srcPath)) migrated[newPath + p.slice(srcPath.length)] = v;
else migrated[p] = v; else migrated[p] = v;
} }
$notebookOrder = migrated; $notebookOrder = migrated;
// Update active notebook/note // Update active notebook/note
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) { if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
selectAllNotes(); selectAllNotes();
} }
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) { if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
const newNotePath = newPath + $activeNotePath.slice(srcPath.length); const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
$activeNotePath = newNotePath; $activeNotePath = newNotePath;
$activeNote = await readNote(newNotePath); $activeNote = await readNote(newNotePath);
@@ -578,7 +636,7 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem} ondblclick={startNewNotebookFromSidebar}> <aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
{#if !isMobile} {#if !isMobile}
<div class="sidebar-header"> <div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar"> <button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
@@ -675,11 +733,13 @@
</nav> </nav>
{/if} {/if}
<div class="section"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="section" ondblclick={startNewNotebookFromSidebar}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="section-header" class="section-header"
class:drop-target={dropTargetPath === '__root__'} class:drop-target={dropTargetPath === '__root__'}
data-nb-root
ondragover={(e) => { ondragover={(e) => {
if (draggedNotebookPath) { if (draggedNotebookPath) {
e.preventDefault(); e.preventDefault();
@@ -903,7 +963,7 @@
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'} class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'} class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {8 + depth * 16}px" style="padding-left: {8 + depth * 16}px"
draggable="true" data-nb-path={nb.path}
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'F2') { if (e.key === 'F2') {
@@ -912,8 +972,6 @@
} }
}} }}
oncontextmenu={(e) => onContextMenu(e, nb)} oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
ondragover={(e) => { ondragover={(e) => {
e.preventDefault(); e.preventDefault();
if (draggedNotebookPath) { if (draggedNotebookPath) {
@@ -978,6 +1036,17 @@
</svg> </svg>
{/if} {/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span> <span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<span
class="nb-drag-handle"
title="Drag to reorder"
onpointerdown={(e) => nbHandleDown(e, nb)}
onclick={(e) => e.stopPropagation()}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
</span>
{/if}
</button> </button>
{/if} {/if}
{#if hasChildren && !isCollapsed} {#if hasChildren && !isCollapsed}
@@ -1307,6 +1376,27 @@
margin-left: 2px; margin-left: 2px;
} }
.nb-drag-handle {
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 2px;
margin-left: 2px;
color: var(--text-tertiary);
opacity: 0.45;
cursor: grab;
touch-action: none;
}
.nb-drag-handle:hover {
opacity: 1;
}
.nb-drag-handle:active {
cursor: grabbing;
}
.tag-list { .tag-list {
padding: 0 4px; padding: 0 4px;
} }
+1 -2
View File
@@ -16,11 +16,10 @@
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded. // Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
const suggestions = $derived.by(() => { const suggestions = $derived.by(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return [];
const taken = new Set(existing); const taken = new Set(existing);
const names = $tags const names = $tags
.map(([name]) => name) .map(([name]) => name)
.filter((name) => !taken.has(name) && name.toLowerCase().includes(q)); .filter((name) => !taken.has(name) && (!q || name.toLowerCase().includes(q)));
names.sort((a, b) => { names.sort((a, b) => {
const ap = a.toLowerCase().startsWith(q) ? 0 : 1; const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
const bp = b.toLowerCase().startsWith(q) ? 0 : 1; const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, syncState } from '$lib/stores/app'; import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api'; import { syncNow } from '$lib/api';
let { onNewNote = () => {}, onDailyNote = () => {} }: { let { onNewNote = () => {}, onDailyNote = () => {} }: {
@@ -112,7 +112,7 @@
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</button> </button>
{#if $appConfig?.sync_provider === 'webdav'} {#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}> <button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/> <path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
+171
View File
@@ -0,0 +1,171 @@
// Obsidian-compatible callouts.
//
// Callouts are written as blockquotes whose first line is `[!type]`:
//
// > [!warning] Optional title
// > Body markdown...
//
// A trailing `+` / `-` on the type makes the callout foldable
// (`+` = expanded, `-` = collapsed). Types are case-insensitive and have
// aliases. Callouts round-trip to this exact syntax so notes stay portable
// (no lock-in) and stay compatible with Obsidian.
// alias (lowercase) -> canonical group used for icon + colour
const ALIASES: Record<string, string> = {
note: 'note',
abstract: 'abstract', summary: 'abstract', tldr: 'abstract',
info: 'info',
todo: 'todo',
tip: 'tip', hint: 'tip', important: 'tip',
success: 'success', check: 'success', done: 'success',
question: 'question', help: 'question', faq: 'question',
warning: 'warning', caution: 'warning', attention: 'warning',
failure: 'failure', fail: 'failure', missing: 'failure',
danger: 'danger', error: 'danger',
bug: 'bug',
example: 'example',
quote: 'quote', cite: 'quote',
};
/**
* Resolve a (possibly aliased / cased) type to its canonical styling group.
* Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon)
* so a `[!decision]` reads as intentional rather than masquerading as a note.
*/
export function calloutGroup(type: string): string {
return ALIASES[(type || '').trim().toLowerCase()] || 'custom';
}
/** Default header label shown when no custom title is set (matches Obsidian). */
export function calloutLabel(type: string): string {
const t = (type || 'note').trim();
return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note';
}
// Inner SVG markup per canonical group (24x24, stroke = currentColor).
const ICONS: Record<string, string> = {
note: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
abstract: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6"/><path d="M9 16h6"/>',
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
todo: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
tip: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>',
success: '<polyline points="20 6 9 17 4 12"/>',
question: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
failure: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
danger: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
bug: '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 8-3 2"/><path d="m5 8 3 2"/><path d="m19 16-3-2"/><path d="m5 16 3-2"/><path d="M21 13h-5"/><path d="M3 13h5"/><path d="m15 4-1.5 2.5"/><path d="m9 4 1.5 2.5"/>',
example: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
quote: '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 0-1 1.03V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
custom: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
};
/** Full SVG icon string for a callout type, at the given pixel size. */
export function calloutIcon(type: string, size = 18): string {
const inner = ICONS[calloutGroup(type)] || ICONS.note;
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
}
/** Ordered list of types offered in the type picker. */
export const CALLOUT_MENU: { type: string; label: string }[] = [
{ type: 'note', label: 'Note' },
{ type: 'abstract', label: 'Abstract' },
{ type: 'info', label: 'Info' },
{ type: 'todo', label: 'Todo' },
{ type: 'tip', label: 'Tip' },
{ type: 'success', label: 'Success' },
{ type: 'question', label: 'Question' },
{ type: 'warning', label: 'Warning' },
{ type: 'failure', label: 'Failure' },
{ type: 'danger', label: 'Danger' },
{ type: 'bug', label: 'Bug' },
{ type: 'example', label: 'Example' },
{ type: 'quote', label: 'Quote' },
];
// First line of a callout: [!type], optional +/- fold marker, optional title.
const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
const BLOCK_SELECTOR =
'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
/**
* Convert callout blockquotes produced by markdown-it into
* `<div data-callout=...>` blocks that the Callout node parses. Operates in
* place on a detached container element. Processes depth-first so nested
* callouts (`> > [!todo]`) are converted before their parents.
*/
export function transformCalloutBlockquotes(root: Element): void {
const walk = (el: Element) => {
for (const child of Array.from(el.children)) walk(child);
if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
};
walk(root);
}
function convertBlockquote(bq: Element): void {
const first = bq.firstElementChild;
if (!first || first.tagName !== 'P') return;
// markdown-it merges the title line and any immediately-following body lines
// into one paragraph (separated by "\n"). Split off the first line.
const innerHtml = (first as HTMLElement).innerHTML;
const nlIdx = innerHtml.indexOf('\n');
const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
const probe = document.createElement('div');
probe.innerHTML = firstLineHtml;
const m = (probe.textContent || '').match(CALLOUT_RE);
if (!m) return;
const type = m[1].toLowerCase();
const foldChar = m[2];
const title = (m[3] || '').trim();
const foldable = foldChar === '+' || foldChar === '-';
const folded = foldChar === '-';
const div = document.createElement('div');
div.setAttribute('data-callout', type);
div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
if (title) div.setAttribute('data-callout-title', title);
if (restHtml.trim()) {
(first as HTMLElement).innerHTML = restHtml;
div.appendChild(first);
} else {
first.remove();
}
while (bq.firstChild) div.appendChild(bq.firstChild);
// content is `block+`; guarantee at least one block.
if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
bq.replaceWith(div);
}
/**
* Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
* `serializeChild` serializes a child block (the editor's own serializer),
* which makes nesting fall out automatically (each `>` line gains another `>`).
*/
export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
const type = node.attrs.type || 'note';
const foldable = !!node.attrs.foldable;
const folded = !!node.attrs.folded;
const title = (node.attrs.title || '').trim();
const suffix = foldable ? (folded ? '-' : '+') : '';
const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
const parts: string[] = [];
node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
while (parts.length && parts[parts.length - 1] === '') parts.pop();
const out = [`> ${header}`];
const inner = parts.join('\n');
if (inner.length) {
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
}
return out.join('\n') + '\n';
}
+14 -5
View File
@@ -1,10 +1,19 @@
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user // Platform is the compile-time build target, injected by the backend into
// agent (not the literal "ios"), so they must be matched explicitly. // window.__HELIX_PLATFORM__ before app scripts run (see src-tauri/src/lib.rs). The UA is only a
// fallback when that global is absent (SSR/prerender, plain-browser dev): some desktop WebKitGTK
// builds report a mobile-looking user-agent. (#63)
type HelixPlatform = { mobile: boolean; android: boolean; ios: boolean };
const injected: HelixPlatform | undefined =
typeof window !== 'undefined'
? (window as unknown as { __HELIX_PLATFORM__?: HelixPlatform }).__HELIX_PLATFORM__
: undefined;
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''; const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua); export const isAndroid = injected ? injected.android : /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua); export const isIOS = injected ? injected.ios : /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS; export const isMobile = injected ? injected.mobile : isAndroid || isIOS;
// Themes that use the dark color scheme. Used by applyTheme() to toggle the // Themes that use the dark color scheme. Used by applyTheme() to toggle the
// `dark` class on the root element. Keep this in sync when adding new themes. // `dark` class on the root element. Keep this in sync when adding new themes.
+18 -5
View File
@@ -1,9 +1,12 @@
import { writable, derived, get } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import { isMobile } from "$lib/platform";
import type { import type {
AppConfig, AppConfig,
CustomTheme,
NoteEntry, NoteEntry,
NoteContent, NoteContent,
NotebookEntry, NotebookEntry,
VaultConfig,
VaultState, VaultState,
ViewMode, ViewMode,
SortMode, SortMode,
@@ -11,11 +14,19 @@ import type {
// App state // App state
export const appConfig = writable<AppConfig | null>(null); export const appConfig = writable<AppConfig | null>(null);
// The active vault's config entry (where per-vault WebDAV sync settings live).
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
if (!c?.active_vault) return null;
return c.vaults.find((v) => v.path === c.active_vault) ?? null;
}
export const vaultReady = writable(false); export const vaultReady = writable(false);
// UI state // UI state
export const viewMode = writable<ViewMode>("all"); export const viewMode = writable<ViewMode>("all");
export const sortMode = writable<SortMode>("modified"); export const sortMode = writable<SortMode>("modified");
// Group the notes list under relative-date headers (All Notes, date sorts only). Persisted per vault.
export const groupNotesByDate = writable<boolean>(false);
export const tasksLayout = writable<"list" | "calendar">("list"); export const tasksLayout = writable<"list" | "calendar">("list");
export const tasksHideCompleted = writable<boolean>(true); export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false); export const tasksOnlyFlagged = writable<boolean>(false);
@@ -24,6 +35,7 @@ export const sidebarCollapsed = writable(false);
export const notelistCollapsed = writable(false); export const notelistCollapsed = writable(false);
export const sidebarWidth = writable(220); export const sidebarWidth = writable(220);
export const notelistWidth = writable(280); export const notelistWidth = writable(280);
export const outlineWidth = writable(220);
export const searchQuery = writable(""); export const searchQuery = writable("");
export const showCommandPalette = writable(false); export const showCommandPalette = writable(false);
export const showSearch = writable(false); export const showSearch = writable(false);
@@ -72,6 +84,7 @@ export const readOnly = writable(false);
// Theme // Theme
export const theme = writable<string>("system"); export const theme = writable<string>("system");
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
// Sync (WebDAV) - global status so the top-bar button reflects any sync, // Sync (WebDAV) - global status so the top-bar button reflects any sync,
// whoever triggered it (manual button, settings, interval, on-change). // whoever triggered it (manual button, settings, interval, on-change).
@@ -87,11 +100,8 @@ export const updateAvailable = writable<{
} | null>(null); } | null>(null);
export const updateObj = writable<any>(null); export const updateObj = writable<any>(null);
export const installType = writable<string>("native"); export const installType = writable<string>("native");
// True only on the Android/iOS build. Defaults to a user-agent guess for the first paint, then // True only on the Android/iOS build. Reconfirmed from the backend at startup (see +layout). (#63)
// gets the authoritative compile-time value from the backend at startup (see +layout). (#63) export const platformIsMobile = writable<boolean>(isMobile);
export const platformIsMobile = writable<boolean>(
typeof navigator !== "undefined" && /android|iphone|ipad|ipod/i.test(navigator.userAgent),
);
export const androidApkUrl = writable<string | null>(null); export const androidApkUrl = writable<string | null>(null);
// Install types that handle their own updates (in-app auto-updater, or a // Install types that handle their own updates (in-app auto-updater, or a
@@ -227,6 +237,7 @@ export const vaultState = derived(
activeNotePath, activeNotePath,
sidebarWidth, sidebarWidth,
notelistWidth, notelistWidth,
outlineWidth,
sidebarCollapsed, sidebarCollapsed,
notelistCollapsed, notelistCollapsed,
collapsedNotebooks, collapsedNotebooks,
@@ -238,6 +249,7 @@ export const vaultState = derived(
$activeNotePath, $activeNotePath,
$sidebarWidth, $sidebarWidth,
$notelistWidth, $notelistWidth,
$outlineWidth,
$sidebarCollapsed, $sidebarCollapsed,
$notelistCollapsed, $notelistCollapsed,
$collapsedNotebooks, $collapsedNotebooks,
@@ -249,6 +261,7 @@ export const vaultState = derived(
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
outline_width: $outlineWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
notelist_collapsed: $notelistCollapsed, notelist_collapsed: $notelistCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
+31
View File
@@ -44,6 +44,34 @@ export interface NoteContent {
export interface VaultConfig { export interface VaultConfig {
path: string; path: string;
name: string; name: string;
// Per-vault WebDAV sync (moved off the global AppConfig).
sync_provider?: string | null;
webdav_url?: string | null;
webdav_username?: string | null;
webdav_password?: string | null;
sync_on_open?: boolean;
sync_on_change?: boolean;
sync_interval_minutes?: number;
last_sync_time?: string | null;
}
export interface CustomThemeColors {
bg_primary: string;
bg_secondary: string;
bg_tertiary: string;
bg_hover: string;
bg_active: string;
bg_editor: string;
text_primary: string;
text_secondary: string;
border_color: string;
}
export interface CustomTheme {
id: string;
name: string;
is_dark: boolean;
colors: CustomThemeColors;
} }
export interface AppConfig { export interface AppConfig {
@@ -103,12 +131,14 @@ export interface AppConfig {
sync_on_change: boolean; sync_on_change: boolean;
sync_interval_minutes: number; sync_interval_minutes: number;
last_sync_time: string | null; last_sync_time: string | null;
custom_themes: CustomTheme[];
} }
export interface VaultState { export interface VaultState {
last_open_note: string | null; last_open_note: string | null;
sidebar_width: number; sidebar_width: number;
notelist_width: number; notelist_width: number;
outline_width?: number;
sidebar_collapsed: boolean; sidebar_collapsed: boolean;
notelist_collapsed: boolean; notelist_collapsed: boolean;
collapsed_notebooks: string[]; collapsed_notebooks: string[];
@@ -116,6 +146,7 @@ export interface VaultState {
notebook_order?: Record<string, number>; notebook_order?: Record<string, number>;
note_order?: Record<string, number>; note_order?: Record<string, number>;
sort_mode?: string; sort_mode?: string;
group_notes_by_date?: boolean;
last_view_mode?: string; last_view_mode?: string;
last_notebook?: string | null; last_notebook?: string | null;
last_tag?: string | null; last_tag?: string | null;
+17
View File
@@ -24,3 +24,20 @@ export function formatDate(dateStr: string): string {
minute: '2-digit' minute: '2-digit'
}); });
} }
// Relative-date bucket label for grouping the notes list:
// Today / Yesterday / Previous 7 Days / Previous 30 Days / "June" (this year) / "June 2025".
// Buckets are contiguous, so a list already sorted by date groups by consecutive label.
export function dateBucketLabel(dateStr: string, now: Date = new Date()): string {
const d = new Date(dateStr);
const dayMs = 86400000;
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const today = startOfDay(now);
const day = startOfDay(d);
if (day >= today) return 'Today';
if (day >= today - dayMs) return 'Yesterday';
if (day >= today - 7 * dayMs) return 'Previous 7 Days';
if (day >= today - 30 * dayMs) return 'Previous 30 Days';
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'long' });
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
}
+37 -11
View File
@@ -1,19 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api'; import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { darkThemes } from '$lib/platform'; import { darkThemes, isMobile, isAndroid } from '$lib/platform';
import type { CustomTheme } from '$lib/types';
import ResizeHandles from '$lib/components/ResizeHandles.svelte'; import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props(); let { children } = $props();
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); // Reactively apply theme class to <html> whenever $theme or custom themes change
// Reactively apply theme class to <html> whenever $theme changes
$effect(() => { $effect(() => {
applyTheme($theme); applyTheme($theme, $customThemes);
}); });
// Apply link arrow visibility from config // Apply link arrow visibility from config
@@ -23,12 +22,41 @@
} }
}); });
function applyTheme(t: string) { const CUSTOM_THEME_VARS = [
'--bg-primary', '--bg-secondary', '--bg-tertiary',
'--bg-hover', '--bg-active', '--bg-editor',
'--text-primary', '--text-secondary', '--border-color',
];
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
root.style.setProperty('--bg-tertiary', ct.colors.bg_tertiary);
root.style.setProperty('--bg-hover', ct.colors.bg_hover);
root.style.setProperty('--bg-active', ct.colors.bg_active);
root.style.setProperty('--bg-editor', ct.colors.bg_editor);
root.style.setProperty('--text-primary', ct.colors.text_primary);
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
root.style.setProperty('--border-color', ct.colors.border_color);
}
function clearCustomThemeVars(root: HTMLElement) {
for (const v of CUSTOM_THEME_VARS) root.style.removeProperty(v);
}
function applyTheme(t: string, themes: CustomTheme[] = []) {
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark']; const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
root.removeAttribute('data-theme'); root.removeAttribute('data-theme');
if (namedThemes.includes(t)) { clearCustomThemeVars(root);
if (t.startsWith('custom-')) {
const ct = themes.find(c => c.id === t);
if (ct) {
applyCustomThemeVars(root, ct);
if (ct.is_dark) root.classList.add('dark');
}
} else if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t); root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark'); if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) { } else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
@@ -46,8 +74,6 @@
return resolved.join('/'); return resolved.join('/');
} }
const isAndroid = /android/i.test(navigator.userAgent);
function openLocalFile(path: string) { function openLocalFile(path: string) {
if (isAndroid) { if (isAndroid) {
const bridge = (window as any).Android; const bridge = (window as any).Android;
@@ -99,7 +125,7 @@
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {}); isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
if (isMobile) { if (isMobile) {
installType.set('android'); installType.set('android');
checkForUpdateMobile(); // Android updates come from the F-droid repo / Obtainium, so no in-app update check.
} else { } else {
getInstallType().then(t => { getInstallType().then(t => {
installType.set(t); installType.set(t);