Compare commits

..
16 Commits
Author SHA1 Message Date
Yuri Karamian 7624c42e02 v1.2.8 - fix url links, tab indent, todo state, graph rework, collapsible sections, nav history, page breaks, folder/tag display 2026-03-27 14:05:59 +01:00
Yuri Karamian d46012b650 update README.md v1.2.7 2026-03-19 21:34:52 +01:00
Yuri Karamian 5d11ae6ea0 update README.md 2026-03-19 20:45:53 +01:00
Yuri Karamian bc372993c4 v1.2.7 - prefix search, keyboard shortcut fixes, GNOME AppImage fix 2026-03-19 19:19:26 +01:00
Yuri Karamian 4897703111 update README.md 2026-03-19 01:39:20 +01:00
Yuri Karamian 1bc067d470 v1.2.6 - unfiled notes, keyboard shortcuts, Android fixes 2026-03-19 00:36:42 +01:00
Yuri Karamian 565688b284 Restore trashed notebooks to their original location instead of vault root 2026-03-17 22:48:24 +01:00
Yuri Karamian e87a2edc00 Add keyboard shortcuts for sidebar, theme, focus mode, read-only, and fullscreen toggles 2026-03-17 22:37:48 +01:00
Yuri Karamian 9f595212dd add unfiled notes 2026-03-17 21:45:51 +01:00
Yuri Karamian 79f16b454a update README.md 2026-03-12 01:06:53 +01:00
Yuri Karamian 7be1b56b7c v1.2.5 - calendar view for daily notes, trash notebook restore, attachment/link fixes 2026-03-12 00:12:45 +01:00
Yuri Karamian 0b17a7c458 update README.md 2026-03-09 18:03:50 +01:00
Yuri Karamian 6c2bc72fbb v1.2.4 - proxy external images through imgproxy protocol for WebKitGTK compat, hide copy/open buttons for external images 2026-03-09 15:58:58 +01:00
Yuri Karamian e67ec21319 update README.md 2026-03-08 19:13:01 +01:00
Yuri Karamian e87c8cbe1d update README.md 2026-03-06 21:49:44 +01:00
Yuri Karamian 4fac6d05a7 update README.md 2026-03-06 21:46:45 +01:00
25 changed files with 2206 additions and 522 deletions
+12 -7
View File
@@ -1,11 +1,16 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.7-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.2.1) ## Download (v1.2.7)
### Linux ### Linux
@@ -23,23 +28,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.7/HelixNotes_1.2.7_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64-setup.exe) (Windows 10/11) [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### Android
[Download APK](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_android.apk) [Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_android.apk)
--- ---
@@ -50,7 +55,7 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
- Markdown editor with toolbar, slash commands, source mode, code highlighting - Markdown editor with toolbar, slash commands, source mode, code highlighting
- `[[Wiki-links]]` and graph view - `[[Wiki-links]]` and graph view
- Full-text search (Tantivy) - Full-text search (Tantivy)
- Outline panel, daily notes, tags, drag-and-drop - Outline panel, daily notes with calendar view, tags, drag-and-drop
- Math (KaTeX), PDF preview, Obsidian import - Math (KaTeX), PDF preview, Obsidian import
- AI writing tools (Ollama / Anthropic / OpenAI) - AI writing tools (Ollama / Anthropic / OpenAI)
- Version history with diffs, automatic backups - Version history with diffs, automatic backups
+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.2.3", "version": "1.2.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+2 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.3" version = "1.2.8"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -3938,6 +3938,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.3" version = "1.2.8"
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"
@@ -35,7 +35,7 @@ tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
regex = "1" regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+2
View File
@@ -12,6 +12,8 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title", "core:window:allow-set-title",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
+124 -37
View File
@@ -112,6 +112,13 @@ pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, S
operations::scan_notebooks(vault_path) operations::scan_notebooks(vault_path)
} }
#[tauri::command]
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, 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")?;
operations::count_root_notes(vault_path)
}
#[tauri::command] #[tauri::command]
pub fn create_notebook( pub fn create_notebook(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -201,9 +208,11 @@ pub fn move_notebook(
#[tauri::command] #[tauri::command]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let vault_path = {
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let config = state.config.lock().map_err(|e| e.to_string())?;
operations::delete_notebook(vault_path, &path) config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::delete_notebook(&vault_path, &path)
} }
// ── Notes ── // ── Notes ──
@@ -248,6 +257,14 @@ pub fn save_note(
drop(config); drop(config);
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -272,10 +289,10 @@ pub fn create_note(
} }
#[tauri::command] #[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String> { pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
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 entry = operations::create_daily_note(vault_path)?; let entry = operations::create_daily_note(vault_path, date.as_deref())?;
if let Ok(search_guard) = state.search_index.lock() { if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard { if let Some(ref search) = *search_guard {
@@ -413,9 +430,14 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
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);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing) // Pass 1: collect ALL notes as nodes (no title deduplication — every note must appear)
let mut graph_nodes = Vec::new(); let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new(); // title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new(); let mut contents: Vec<String> = Vec::new();
@@ -445,12 +467,18 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
path.file_stem().unwrap_or_default().to_string_lossy().to_string() path.file_stem().unwrap_or_default().to_string_lossy().to_string()
}); });
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len(); let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx); let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode { graph_nodes.push(crate::types::GraphNode {
title, title,
path: path_str, path: path_str,
@@ -459,8 +487,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
} }
// Pass 2: extract edges from wiki-links (inline scan, no regex) // Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new(); // edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edge_set: HashSet<(usize, usize)> = HashSet::new(); let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists — mark it as bidirectional
edges[rev_idx].bidirectional = true;
} else {
let idx = edges.len();
edge_map.insert((src, tgt), idx);
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
}
};
for (source_idx, body) in contents.iter().enumerate() { for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes(); let bytes = body.as_bytes();
@@ -481,11 +523,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let link = link.split('^').next().unwrap_or(link); let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase(); let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) { // 1. Try vault-relative path match (e.g. "arkhost/note name")
if target_idx != source_idx { if let Some(&target_idx) = relpath_to_idx.get(&link) {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) }; add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
if edge_set.insert(key) { } else if let Some(targets) = title_to_idxs.get(&link) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx }); // 2. Title match — connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} }
} }
} }
@@ -554,10 +606,10 @@ pub fn reindex(state: State<'_, AppState>) -> Result<(), String> {
// ── Trash ── // ── Trash ──
#[tauri::command] #[tauri::command]
pub fn get_trash(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> { pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, String> {
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")?;
operations::get_trash_notes(vault_path) operations::get_trash_contents(vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -566,14 +618,29 @@ pub fn restore_note(
trash_path: String, trash_path: String,
dest_notebook: Option<String>, dest_notebook: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let vault_path = {
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let config = state.config.lock().map_err(|e| e.to_string())?;
operations::restore_note(vault_path, &trash_path, dest_notebook.as_deref()) config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
} }
#[tauri::command] #[tauri::command]
pub fn permanent_delete(path: String) -> Result<(), String> { pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
operations::permanent_delete(&path) let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_notebook(&vault_path, &trash_path)
}
#[tauri::command]
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::permanent_delete(&vault_path, &path)
} }
#[tauri::command] #[tauri::command]
@@ -1338,34 +1405,54 @@ fn resolve_wiki_ref(
reference.to_string() reference.to_string()
} }
// ── Open file with system default handler ── // ── Open file/URL with system default handler ──
#[tauri::command] fn xdg_open(arg: &str) -> Result<(), String> {
pub fn open_file(path: String) -> Result<(), String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
std::process::Command::new("xdg-open") let mut cmd = std::process::Command::new("xdg-open");
.arg(&path) cmd.arg(arg);
.spawn() // Clear AppImage environment so child processes find host binaries
.map_err(|e| format!("Failed to open {}: {}", path, e))?; // (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
std::process::Command::new("open") std::process::Command::new("open")
.arg(&path) .arg(arg)
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::process::Command::new("cmd") std::process::Command::new("cmd")
.args(["/C", "start", "", &path]) .args(["/C", "start", "", arg])
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command] #[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
+97 -1
View File
@@ -98,6 +98,7 @@ pub fn run() {
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::get_notebooks, commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook, commands::create_notebook,
commands::rename_notebook, commands::rename_notebook,
commands::delete_notebook, commands::delete_notebook,
@@ -117,6 +118,7 @@ pub fn run() {
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
commands::restore_note, commands::restore_note,
commands::restore_notebook,
commands::permanent_delete, commands::permanent_delete,
commands::empty_trash, commands::empty_trash,
commands::load_vault_state, commands::load_vault_state,
@@ -135,6 +137,7 @@ pub fn run() {
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
@@ -149,7 +152,80 @@ pub fn run() {
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file, commands::get_pending_open_file,
]); ])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
let path = request.uri().path().to_string();
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
@@ -251,3 +327,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+32 -9
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tantivy::directory::RamDirectory; use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; use walkdir::WalkDir;
pub struct SearchIndex { pub struct SearchIndex {
@@ -152,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let fields = vec![self.title_field, self.body_field, self.tags_field];
&self.index, let terms: Vec<String> = query_str
vec![self.title_field, self.body_field, self.tags_field], .split_whitespace()
); .map(|t| t.to_lowercase())
.collect();
let query = query_parser // For each search term, create prefix + fuzzy queries across all fields (OR),
.parse_query(query_str) // then AND all terms together
.map_err(|e| e.to_string())?; let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit)) .search(&query, &TopDocs::with_limit(limit))
+15
View File
@@ -19,6 +19,20 @@ pub struct NoteEntry {
pub preview: String, pub preview: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashContents {
pub notes: Vec<NoteEntry>,
pub notebooks: Vec<TrashNotebookEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashNotebookEntry {
pub name: String,
pub path: String,
pub note_count: usize,
pub modified: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotebookEntry { pub struct NotebookEntry {
pub name: String, pub name: String,
@@ -271,4 +285,5 @@ pub struct GraphNode {
pub struct GraphEdge { pub struct GraphEdge {
pub source: usize, pub source: usize,
pub target: usize, pub target: usize,
pub bidirectional: bool,
} }
+133 -11
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Locale, Utc}; use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -63,7 +63,9 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut dirs: Vec<_> = read_dir let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path()) e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
}) })
.collect(); .collect();
@@ -165,6 +167,22 @@ fn is_hidden(path: &Path) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> { pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path); let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path); let root = Path::new(scan_path);
@@ -502,11 +520,15 @@ fn get_system_locale() -> Locale {
} }
} }
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
let today = Local::now(); let target_date = match date {
let date_str = today.format("%Y-%m-%d").to_string(); Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let locale = get_system_locale(); let locale = get_system_locale();
let title = today.format_localized("%B %d, %Y", locale).to_string(); let title = target_date.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily"); let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -621,6 +643,15 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
let trash_name = format!("{}_{}", timestamp, dirname); let trash_name = format!("{}_{}", timestamp, dirname);
let dest = trash_dir.join(&trash_name); let dest = trash_dir.join(&trash_name);
// Save original relative path in a sidecar .meta file for restore
let relative = src
.strip_prefix(vault_path)
.unwrap_or(src)
.to_string_lossy()
.to_string();
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
let _ = fs::write(&meta_path, &relative);
fs::rename(src, dest).map_err(|e| e.to_string())?; fs::rename(src, dest).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@@ -854,14 +885,29 @@ pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, S
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> { /// Remove a directory inside trash if it's empty (after restoring/deleting its last note).
fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if let Some(d) = dir {
if d != trash_dir && d.starts_with(&trash_dir) {
if let Ok(mut entries) = fs::read_dir(d) {
if entries.next().is_none() {
let _ = fs::remove_dir(d);
}
}
}
}
}
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { if !trash_dir.exists() {
return Ok(Vec::new()); return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
} }
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
let mut notes = Vec::new(); let mut notes = Vec::new();
let mut notebooks = Vec::new();
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? { for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
@@ -870,11 +916,39 @@ pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
if let Ok(note) = read_note_entry(&path, vault_root) { if let Ok(note) = read_note_entry(&path, vault_root) {
notes.push(note); notes.push(note);
} }
} else if path.is_dir() {
// Deleted notebook — count .md files inside
let note_count = WalkDir::new(&path)
.min_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
.count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
dirname[18..].to_string()
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
dirname[15..].to_string()
} else {
dirname.to_string()
};
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
name,
path: path.to_string_lossy().to_string(),
note_count,
modified,
});
} }
} }
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes) notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(TrashContents { notes, notebooks })
} }
pub fn restore_note( pub fn restore_note(
@@ -902,18 +976,66 @@ pub fn restore_note(
&filename &filename
}; };
let parent = src.parent().map(|p| p.to_path_buf());
let dest = dest_dir.join(original_name); let dest = dest_dir.join(original_name);
fs::rename(src, &dest).map_err(|e| e.to_string())?; fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn permanent_delete(path: &str) -> Result<(), String> { pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() || !src.is_dir() {
return Err("Trashed notebook does not exist".to_string());
}
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
// Fallback for notebooks trashed before .meta files existed:
// strip timestamp prefix to get notebook name, restore to root
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
} else {
&dirname
};
name.to_string()
};
let dest = Path::new(vault_path).join(&relative);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up .meta file
let _ = fs::remove_file(&meta_path);
Ok(dest.to_string_lossy().to_string())
}
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path); let p = Path::new(path);
let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() { if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?; fs::remove_dir_all(p).map_err(|e| e.to_string())?;
} else { } else {
fs::remove_file(p).map_err(|e| e.to_string())?; fs::remove_file(p).map_err(|e| e.to_string())?;
} }
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(()) Ok(())
} }
+2 -2
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.2.3", "version": "1.2.8",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost", "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
+16 -3
View File
@@ -7,6 +7,7 @@ import type {
NotebookEntry, NotebookEntry,
NoteTitleEntry, NoteTitleEntry,
SearchResult, SearchResult,
TrashContents,
VaultState, VaultState,
VaultStats, VaultStats,
ImportResult, ImportResult,
@@ -46,6 +47,10 @@ export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
export async function countRootNotes(): Promise<number> {
return invoke("count_root_notes");
}
export async function createNotebook( export async function createNotebook(
parentRelative: string | null, parentRelative: string | null,
name: string, name: string,
@@ -96,8 +101,8 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title }); return invoke("create_note", { notebookRelative, title });
} }
export async function createDailyNote(): Promise<NoteEntry> { export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note"); return invoke("create_daily_note", { date: date ?? null });
} }
export async function renameNote( export async function renameNote(
@@ -141,7 +146,7 @@ export async function reindex(): Promise<void> {
return invoke("reindex"); return invoke("reindex");
} }
export async function getTrash(): Promise<NoteEntry[]> { export async function getTrash(): Promise<TrashContents> {
return invoke("get_trash"); return invoke("get_trash");
} }
@@ -152,6 +157,10 @@ export async function restoreNote(
return invoke("restore_note", { trashPath, destNotebook }); return invoke("restore_note", { trashPath, destNotebook });
} }
export async function restoreNotebook(trashPath: string): Promise<string> {
return invoke("restore_notebook", { trashPath });
}
export async function permanentDelete(path: string): Promise<void> { export async function permanentDelete(path: string): Promise<void> {
return invoke("permanent_delete", { path }); return invoke("permanent_delete", { path });
} }
@@ -258,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo( export async function copyFileTo(
source: string, source: string,
destination: string, destination: string,
+58 -38
View File
@@ -33,14 +33,17 @@
quickAccessPaths, quickAccessPaths,
tags, tags,
notes, notes,
viewMode,
activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab settingsTab,
navHistory
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
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';
@@ -57,9 +60,7 @@
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number { function parseFrequencyMs(freq: string): number {
switch (freq) { switch (freq) {
@@ -90,32 +91,19 @@
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
if (navigatingFromHistory) { if (path) navHistory.push(path);
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
}); });
function navigateHistory(direction: -1 | 1) { function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction; const path = navHistory.go(direction);
if (newIndex < 0 || newIndex >= noteHistory.length) return; if (!path) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave(); editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(path, content.content); editor?.loadNote(path, content.content);
}).catch(() => { }).catch(() => {});
// Note may have been deleted, ignore
});
} }
async function handleOpenFile(filePath: string) { async function handleOpenFile(filePath: string) {
@@ -183,7 +171,8 @@
$activeNotePath = entry.path; $activeNotePath = entry.path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(entry.path, content.content); editor?.loadNote(entry.path, content.content);
noteList?.refresh(); $viewMode = 'daily';
noteList?.refresh(true);
sidebar?.refresh(); sidebar?.refresh();
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
@@ -250,6 +239,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') { if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
navigateHistory(-1); navigateHistory(-1);
@@ -260,19 +250,25 @@
navigateHistory(1); navigateHistory(1);
return; return;
} }
if (mod && !e.shiftKey && e.key === 'n') { if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return;
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
@@ -281,28 +277,50 @@
} }
return; return;
} }
if (mod && e.key === 'p') { if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return;
} }
if (mod && e.key === 's') { if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') { if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath && $activeNote) { if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
} }
} }
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -402,9 +420,11 @@
debouncedRefresh(); debouncedRefresh();
}); });
} else { } else {
unlistenFileChange = await listen<FileEvent>('file-changed', async () => { const debouncedDesktopRefresh = debounce(async () => {
await sidebar?.refresh(); await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
await noteList?.refresh(true); }, 300);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedDesktopRefresh();
}); });
} }
@@ -460,7 +480,7 @@
{#if $mobileView === 'sidebar'} {#if $mobileView === 'sidebar'}
HelixNotes HelixNotes
{:else} {:else}
{$activeNotebook?.name || 'All Notes'} {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if} {/if}
</span> </span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'} {#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -564,7 +584,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note"> <button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -629,7 +649,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
+514 -80
View File
@@ -29,16 +29,15 @@
import katex from 'katex'; import katex from 'katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core'; import { Extension, Node as TiptapNode, Mark as TiptapMark, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey, EditorState } from '@tiptap/pm/state'; import { Plugin, PluginKey, EditorState, TextSelection } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view'; import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { DOMSerializer } from '@tiptap/pm/model'; import { DOMSerializer } from '@tiptap/pm/model';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { openFile, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes, navHistory, canGoBack, canGoForward } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types'; import type { VersionEntry, AiStreamEvent, NoteTitleEntry } from '$lib/types';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
@@ -177,9 +176,10 @@
{ label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() }, { label: 'Task List', aliases: ['checklist', 'checkbox', 'todo', 'check'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="M5 8l1.5 1.5L9 7"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>', action: () => editor?.chain().focus().toggleTaskList().run() },
{ label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() }, { label: 'Code Block', aliases: ['code', 'codeblock', 'pre', 'snippet'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>', action: () => editor?.chain().focus().toggleCodeBlock().run() },
{ label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() }, { label: 'Blockquote', aliases: ['quote', 'blockquote', 'citation'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/></svg>', action: () => editor?.chain().focus().toggleBlockquote().run() },
{ label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => editor?.chain().focus().setDetails().run() }, { label: 'Collapsible Section', aliases: ['details', 'accordion', 'collapse', 'toggle', 'summary'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>', action: () => insertDetails() },
{ label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } }, { label: 'Table', aliases: ['table', 'grid'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>', action: () => { slashTablePicker = true; slashTableHover = { rows: 0, cols: 0 }; } },
{ label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() }, { label: 'Horizontal Rule', aliases: ['hr', 'divider', 'line', 'separator', 'rule'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="2" y1="12" x2="22" y2="12"/></svg>', action: () => editor?.chain().focus().setHorizontalRule().run() },
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() },
]; ];
} }
@@ -199,6 +199,7 @@
let linkModalInput = $state<HTMLInputElement>(null!); let linkModalInput = $state<HTMLInputElement>(null!);
let linkSelectionFrom = 0; let linkSelectionFrom = 0;
let linkSelectionTo = 0; let linkSelectionTo = 0;
let linkModalDisplayText = '';
let linkSuggestIndex = $state(0); let linkSuggestIndex = $state(0);
let linkSuggestTitles = $state<NoteTitleEntry[]>([]); let linkSuggestTitles = $state<NoteTitleEntry[]>([]);
let linkSuggestFiltered = $derived.by(() => { let linkSuggestFiltered = $derived.by(() => {
@@ -213,6 +214,7 @@
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null); let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null); let copyToast = $state<'copying' | 'done' | null>(null);
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : ''); let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let noteFolder = $derived(noteRelativePath ? noteRelativePath.substring(0, noteRelativePath.lastIndexOf('/')) : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
@@ -306,24 +308,23 @@
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes.src || ''; const src = HTMLAttributes.src || '';
const name = HTMLAttributes.name || 'file.pdf'; const name = HTMLAttributes.name || 'file.pdf';
if (isMobile) { const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
// On mobile, render as a clickable link instead of iframe if (showInline) {
const vaultRoot = $appConfig?.active_vault ?? ''; const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`); const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }), const displaySrc = convertFileSrc(absPath);
['a', { href: '#', 'data-open-file': absPath, class: 'pdf-link-mobile', onclick: 'return false;' }, return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed' }),
['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'], ['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }],
['span', {}, name], ['p', { class: 'pdf-label' }, name],
],
]; ];
} }
const vaultRoot = $appConfig?.active_vault ?? ''; // Non-inline: render as a clickable link (mobile + desktop with setting off)
const pdfHeight = $appConfig?.pdf_height ?? 600; return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }),
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`); ['a', { href: decodeURIComponent(src), class: 'pdf-link-mobile' },
const displaySrc = convertFileSrc(absPath); ['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'],
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed' }), ['span', {}, name],
['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }], ],
['p', { class: 'pdf-label' }, name],
]; ];
}, },
}); });
@@ -389,6 +390,27 @@
}, },
}); });
const PageBreak = TiptapNode.create({
name: 'pageBreak',
group: 'block',
atom: true,
parseHTML() {
return [
{ tag: 'div[data-page-break]' },
{
tag: 'div',
getAttrs: (el: HTMLElement) => {
const style = el.getAttribute('style') || '';
return style.includes('page-break-after') ? {} : false;
},
},
];
},
renderHTML() {
return ['div', { 'data-page-break': 'true', style: 'page-break-after: always; break-after: page;', class: 'page-break' }];
},
});
let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null); let codeLangDropdown = $state<{ pos: number; x: number; y: number; current: string } | null>(null);
function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) { function openCodeLangDropdown(pos: number, current: string, triggerEl: HTMLElement) {
@@ -497,6 +519,30 @@
}, },
}); });
// Tab inserts a tab character in plain paragraphs/headings.
// Priority 50 < default 100, so list/task/table/codeblock extensions handle Tab first for their own nodes.
const TabIndent = Extension.create({
name: 'tabIndent',
priority: 50,
addKeyboardShortcuts() {
return {
Tab: () => {
const sel = this.editor.state.selection;
const from = sel.$from;
const node = from.node();
if (node.type.name !== 'paragraph' && node.type.name !== 'heading') return false;
// Don't intercept if inside a list or task item (their extensions handle Tab first,
// but guard here too in case of priority edge cases)
for (let d = from.depth - 1; d > 0; d--) {
const name = from.node(d).type.name;
if (name === 'listItem' || name === 'taskItem') return false;
}
return this.editor.commands.insertContent('\t');
},
};
},
});
const CodeBlockLanguageSelect = Extension.create({ const CodeBlockLanguageSelect = Extension.create({
name: 'codeBlockLanguageSelect', name: 'codeBlockLanguageSelect',
addGlobalAttributes() { addGlobalAttributes() {
@@ -597,6 +643,22 @@
slashTableHover = { rows: 0, cols: 0 }; slashTableHover = { rows: 0, cols: 0 };
} }
$effect(() => {
if (!slashMenu || slashSelectedIndex < 0) return;
slashSelectedIndex; // track
tick().then(() => {
document.querySelector('.slash-menu .slash-menu-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
$effect(() => {
if (!wikiLinkMenu || wikiLinkSelectedIndex < 0) return;
wikiLinkSelectedIndex; // track
tick().then(() => {
document.querySelector('.wiki-link-menu .wiki-link-item.selected')?.scrollIntoView({ block: 'nearest' });
});
});
function updateSlashMenu() { function updateSlashMenu() {
const wasSlashTyped = slashTypedByUser; const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false; slashTypedByUser = false;
@@ -639,9 +701,11 @@
// Keep menu within viewport (account for virtual keyboard on mobile) // Keep menu within viewport (account for virtual keyboard on mobile)
if (x + 240 > window.innerWidth) x = window.innerWidth - 250; if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight; const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300); const menuHeight = 300;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
slashMenu = { x, y, query, from, to }; slashMenu = { x, y, query, from, to };
slashSelectedIndex = 0; slashSelectedIndex = 0;
@@ -761,9 +825,22 @@
if (pipeIdx >= 0) q = q.slice(0, pipeIdx); if (pipeIdx >= 0) q = q.slice(0, pipeIdx);
q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); q = q.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!q) return wikiLinkTitlesCache; if (!q) return wikiLinkTitlesCache;
return wikiLinkTitlesCache.filter(entry => // Score: 0 = exact, 1 = starts-with, 2 = word-start, 3 = contains
entry.title.toLowerCase().includes(q) const scored = wikiLinkTitlesCache
); .map(entry => {
const t = entry.title.toLowerCase();
let score: number;
if (t === q) score = 0;
else if (t.startsWith(q)) score = 1;
else if (t.includes(' ' + q) || t.includes('-' + q)) score = 2;
else if (t.includes(q)) score = 3;
else score = -1;
return { entry, score };
})
.filter(x => x.score >= 0)
.sort((a, b) => a.score - b.score)
.map(x => x.entry);
return scored;
}); });
// Set of lowercase titles that appear more than once (for disambiguation display) // Set of lowercase titles that appear more than once (for disambiguation display)
@@ -1047,9 +1124,11 @@
const coords = editor.view.coordsAtPos(from); const coords = editor.view.coordsAtPos(from);
let x = coords.left; let x = coords.left;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290; if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight; const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300); const menuHeight = 360;
let y = coords.bottom + 4;
if (y + menuHeight > visibleBottom) y = coords.top - menuHeight - 4;
if (y < 4) y = 4;
wikiLinkMenu = { x, y, query, from }; wikiLinkMenu = { x, y, query, from };
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
} }
@@ -1180,10 +1259,19 @@
]; ];
function resolveImageSrc(src: string): string { function resolveImageSrc(src: string): string {
// Already an absolute URL or data URI // Already a proxied, asset, data, or blob URL
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:')) { if (src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:') || src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
return src; return src;
} }
// Already an asset-localhost URL
if (src.startsWith('http://asset.localhost') || src.startsWith('https://asset.localhost')) {
return src;
}
// External http/https URLs: proxy through Tauri's imgproxy protocol
// to bypass WebKitGTK restrictions on loading external resources
if (src.startsWith('http://') || src.startsWith('https://')) {
return convertFileSrc(src, 'imgproxy');
}
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution // Decode percent-encoding (%20 → space, etc.) for filesystem resolution
let decoded = decodeURIComponent(src); let decoded = decodeURIComponent(src);
// Fix multiple leading slashes (from broken saves) // Fix multiple leading slashes (from broken saves)
@@ -1368,6 +1456,18 @@
}); });
} }
async function editorNavigateHistory(direction: -1 | 1) {
const path = navHistory.go(direction);
if (!path) return;
flushSave();
try {
const content = await readNote(path);
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
} catch {}
}
/** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save). /** Flush unsaved editor content to disk (synchronous serialize + fire-and-forget save).
* Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */ * Call BEFORE updating $activeNote/$activeNotePath stores when switching notes. */
export function flushSave() { export function flushSave() {
@@ -1395,9 +1495,11 @@
const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false); const shouldBeReadOnly = isNewNote ? false : ($appConfig?.default_view_mode ?? false);
$readOnly = shouldBeReadOnly; $readOnly = shouldBeReadOnly;
if (editor) editor.setEditable(!shouldBeReadOnly); if (editor) editor.setEditable(!shouldBeReadOnly);
const editorBody = editorElement?.closest('.editor-body') as HTMLElement | null;
if ($sourceMode) { if ($sourceMode) {
sourceContent = stripTitleH1(content); sourceContent = stripTitleH1(content);
resetSourceHistory(sourceContent); resetSourceHistory(sourceContent);
if (editorBody) editorBody.scrollTop = 0;
isLoadingNote = false; isLoadingNote = false;
} else if (editorElement && editor) { } else if (editorElement && editor) {
// Editor already exists, just swap content // Editor already exists, just swap content
@@ -1406,8 +1508,19 @@
editor.commands.setContent(html); editor.commands.setContent(html);
// Clear undo/redo history so it doesn't bleed across notes // Clear undo/redo history so it doesn't bleed across notes
clearEditorHistory(); clearEditorHistory();
// Clear loading flag after setContent updates have fired // Reset scroll and cursor after all ProseMirror/Svelte DOM updates settle
tick().then(() => { isLoadingNote = false; }); tick().then(() => {
if (editorBody) editorBody.scrollTop = 0;
// Explicitly reset ProseMirror selection to start so TipTap's focus()
// (triggered by checkbox clicks etc.) doesn't scroll to the old note's cursor position.
if (editor) {
const tr = editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc));
// No tr.scrollIntoView() — must not trigger any scroll
editor.view.dispatch(tr);
}
requestAnimationFrame(() => { if (editorBody) editorBody.scrollTop = 0; });
isLoadingNote = false;
});
} else { } else {
// Editor element not in DOM yet (first note load). // Editor element not in DOM yet (first note load).
// Store content and let the $effect on editorElement handle init. // Store content and let the $effect on editorElement handle init.
@@ -1562,6 +1675,8 @@
return serializeListItem(node); return serializeListItem(node);
case 'horizontalRule': case 'horizontalRule':
return '---\n'; return '---\n';
case 'pageBreak':
return '<div style="page-break-after: always;"></div>\n';
case 'table': { case 'table': {
// Preserve tables as raw HTML // Preserve tables as raw HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
@@ -1876,6 +1991,15 @@
function stripAssetSrc(src: string): string { function stripAssetSrc(src: string): string {
// blob: URLs are not persistable — they were temporary browser references // blob: URLs are not persistable — they were temporary browser references
if (src.startsWith('blob:')) return ''; if (src.startsWith('blob:')) return '';
// Convert imgproxy:// URLs back to original external URLs for saving
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
try {
const url = new URL(src);
return decodeURIComponent(url.pathname.substring(1));
} catch {
return src;
}
}
// Convert asset:// URLs back to relative paths for saving // Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src; if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src;
let absPath = ''; let absPath = '';
@@ -2067,16 +2191,17 @@
return `[${text}](${url.replace(/ /g, '%20')})`; return `[${text}](${url.replace(/ /g, '%20')})`;
}); });
// Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile // Pre-process: transform PDF embed divs — iframes when inline preview is on, clickable links otherwise
src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => { src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => {
const vaultRoot = $appConfig?.active_vault ?? ''; const vaultRoot = $appConfig?.active_vault ?? '';
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`); const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`);
if (isMobile) { const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
return `<div class="pdf-embed-mobile"><a href="#" data-open-file="${absPath}" class="pdf-link-mobile" onclick="return false;">\uD83D\uDCC4 ${name}</a></div>`; if (showInline) {
const pdfHeight = $appConfig?.pdf_height ?? 600;
const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
} }
const pdfHeight = $appConfig?.pdf_height ?? 600; return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed-mobile"><a href="${decodeURIComponent(pdfSrc)}" class="pdf-link-mobile">\uD83D\uDCC4 ${name}</a></div>`;
const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
}); });
// Pre-process: render KaTeX math — only outside fenced code blocks // Pre-process: render KaTeX math — only outside fenced code blocks
@@ -2272,12 +2397,62 @@
PdfEmbed, PdfEmbed,
MathBlock, MathBlock,
MathInline, MathInline,
Details.configure({ persist: false, HTMLAttributes: { class: 'editor-details' } }), PageBreak,
Details.configure({ persist: true, HTMLAttributes: { class: 'editor-details' } }),
DetailsSummary, DetailsSummary,
DetailsContent, DetailsContent,
Extension.create({
name: 'collapsibleKeymap',
addProseMirrorPlugins() {
return [new Plugin({
key: new PluginKey('collapsibleKeymap'),
props: {
handleDOMEvents: {
keydown(view, event) {
const isTab = event.key === 'Tab' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
const isEnter = event.key === 'Enter' && !event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTab && !isEnter) return false;
const { schema, selection } = view.state;
const from = selection.$from;
let summaryDepth = -1;
for (let d = from.depth; d >= 0; d--) {
if (from.node(d).type === schema.nodes.detailsSummary) { summaryDepth = d; break; }
}
if (summaryDepth === -1) return false;
event.preventDefault();
const detailsDepth = summaryDepth - 1;
const detailsNode = from.node(detailsDepth);
let detailsContentPos: number | null = null;
let pos = from.start(detailsDepth);
for (let i = 0; i < detailsNode.childCount; i++) {
const child = detailsNode.child(i);
if (child.type === schema.nodes.detailsContent) { detailsContentPos = pos + 1; break; }
pos += child.nodeSize;
}
if (detailsContentPos === null) return true;
// Open the section if it is closed
const domPos = view.domAtPos(from.pos);
let domNode = domPos.node as HTMLElement;
if (domNode.nodeType === 3) domNode = domNode.parentElement as HTMLElement;
const detailsEl = domNode?.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open state into document + move cursor (single transaction)
const detailsPos = from.before(detailsDepth);
view.dispatch(view.state.tr
.setNodeMarkup(detailsPos, undefined, { open: true })
.setSelection(TextSelection.create(view.state.doc, detailsContentPos))
);
return true;
}
}
}
})];
}
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }), TextAlign.configure({ types: ['heading', 'paragraph'] }),
SlashCommands, SlashCommands,
MoveLineShortcuts, MoveLineShortcuts,
TabIndent,
NoteSearchExtension, NoteSearchExtension,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
], ],
@@ -2285,9 +2460,11 @@
editorProps: { editorProps: {
attributes: { class: 'editor-content' }, attributes: { class: 'editor-content' },
handleDOMEvents: { handleDOMEvents: {
// Prevent details toggle button from stealing focus, which causes scroll-to-top. // Prevent focus-caused scroll jumps when clicking details toggle buttons.
// Also pre-focus the editor with preventScroll so TipTap's focus command // Pre-focusing with preventScroll means TipTap's focus() call sees
// sees hasFocus()=true and skips its scrolling view.focus() call. // hasFocus()=true and skips its scrolling view.focus() call.
// For task checkboxes: lock scroll on mousedown (before any dispatch fires)
// so that any synchronous or async scroll caused by the toggle is reverted.
mousedown: (view, event) => { mousedown: (view, event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('[data-type="details"] > button')) { if (target.closest('[data-type="details"] > button')) {
@@ -2296,21 +2473,17 @@
(view.dom as HTMLElement).focus({ preventScroll: true }); (view.dom as HTMLElement).focus({ preventScroll: true });
} }
} }
}, if (target.closest('[data-type="taskItem"] input[type="checkbox"]')) {
// On mobile, handle clicks on PDF file links to open in external app const editorBody = target.closest('.editor-body') as HTMLElement | null;
...(isMobile ? { click: (_view: any, event: MouseEvent) => { if (editorBody) {
const target = (event.target as HTMLElement).closest('[data-open-file]') as HTMLElement | null; const savedScroll = editorBody.scrollTop;
if (target) { const restore = () => { editorBody!.scrollTop = savedScroll; };
event.preventDefault(); editorBody.addEventListener('scroll', restore);
const filePath = target.getAttribute('data-open-file'); // Remove after 200ms — covers synchronous, rAF, and setTimeout-based scrolls
if (filePath) { setTimeout(() => editorBody!.removeEventListener('scroll', restore), 200);
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(filePath);
}
} }
} }
}} : {}), },
// Prevent native text drag — it causes copy-instead-of-move in Tauri's webview. // Prevent native text drag — it causes copy-instead-of-move in Tauri's webview.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead. // File drops from OS are handled by Tauri's onDragDropEvent listener instead.
dragstart: (_view, event) => { dragstart: (_view, event) => {
@@ -2398,10 +2571,21 @@
} }
// Store raw URL — encoding is handled during markdown serialization/parsing // Store raw URL — encoding is handled during markdown serialization/parsing
const href = url.replace(/[()]/g, (c) => encodeURIComponent(c)); const href = url.replace(/[()]/g, (c) => encodeURIComponent(c));
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run(); if (linkSelectionFrom === linkSelectionTo) {
// No text selected — insert link text with the mark
const text = linkModalDisplayText || url.replace(/\.md$/, '').split('/').pop() || url;
editor.chain().focus().setTextSelection(linkSelectionFrom).insertContent({
type: 'text',
text,
marks: [{ type: 'link', attrs: { href } }],
}).run();
} else {
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run();
}
} }
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
linkModalDisplayText = '';
} }
function linkModalSelectNote(entry: NoteTitleEntry) { function linkModalSelectNote(entry: NoteTitleEntry) {
@@ -2421,12 +2605,14 @@
} else { } else {
linkModalUrl = entry.title + '.md'; linkModalUrl = entry.title + '.md';
} }
linkModalDisplayText = entry.title;
linkModalConfirm(); linkModalConfirm();
} }
function linkModalCancel() { function linkModalCancel() {
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
linkModalDisplayText = '';
editor?.chain().focus().run(); editor?.chain().focus().run();
} }
@@ -2738,8 +2924,31 @@
closeTextContextMenu(); closeTextContextMenu();
} }
function openDetailsEl(el: HTMLElement) {
if (!el.classList.contains('is-open')) {
el.classList.add('is-open');
(el.querySelector('[data-type="detailsContent"]') as HTMLElement | null)
?.dispatchEvent(new Event('toggleDetailsContent'));
}
}
function insertDetails() {
if (!editor) return;
editor.chain().focus().setDetails().run();
requestAnimationFrame(() => {
if (!editor) return;
const domPos = editor.view.domAtPos(editor.state.selection.from);
let node = domPos.node as HTMLElement;
if (node.nodeType === 3) node = node.parentElement as HTMLElement;
const detailsEl = node.closest('[data-type="details"]') as HTMLElement | null;
if (detailsEl) openDetailsEl(detailsEl);
// Sync open: true into the document so it saves with the note
editor.chain().updateAttributes('details', { open: true }).run();
});
}
function ctxDetails() { function ctxDetails() {
editor?.chain().focus().setDetails().run(); insertDetails();
closeTextContextMenu(); closeTextContextMenu();
} }
@@ -2996,19 +3205,7 @@
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch(console.error); openUrl(href).catch(console.error);
} else { } else {
const decoded = decodeURIComponent(href); openFile(resolveHrefToAbsPath(href)).catch(console.error);
let absPath = decoded;
if (!decoded.startsWith('/')) {
const np = $activeNotePath;
if (np) {
const noteDir = np.substring(0, np.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch(console.error);
} }
} }
@@ -3028,8 +3225,13 @@
if (pos >= 0) { if (pos >= 0) {
editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run(); editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run();
} }
const { from, to } = editor.state.selection;
linkSelectionFrom = from;
linkSelectionTo = to;
linkModalUrl = decodeURIComponent(href); linkModalUrl = decodeURIComponent(href);
linkModalDisplayText = '';
linkModal = true; linkModal = true;
getAllNoteTitles().then(t => { linkSuggestTitles = t; }).catch(() => {});
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -3053,12 +3255,16 @@
function resolveHrefToAbsPath(href: string): string { function resolveHrefToAbsPath(href: string): string {
const decoded = decodeURIComponent(href); const decoded = decodeURIComponent(href);
if (decoded.startsWith('/')) return decoded; if (decoded.startsWith('/')) return decoded;
// .helixnotes/ paths are always relative to vault root, not the note's directory
const vaultRoot = $appConfig?.active_vault;
if (decoded.startsWith('.helixnotes/') && vaultRoot) {
return normalizePath(`${vaultRoot}/${decoded}`);
}
const notePath = $activeNotePath; const notePath = $activeNotePath;
if (notePath) { if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`); return normalizePath(`${noteDir}/${decoded}`);
} }
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`); if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return decoded; return decoded;
} }
@@ -3414,6 +3620,16 @@
</div> </div>
{#if !isMobile} {#if !isMobile}
<div class="toolbar-actions"> <div class="toolbar-actions">
{#if $canGoBack || $canGoForward}
<div class="nav-history-btns">
<button class="nav-history-btn" disabled={!$canGoBack} onclick={() => editorNavigateHistory(-1)} title="Back (Alt+←)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="nav-history-btn" disabled={!$canGoForward} onclick={() => editorNavigateHistory(1)} title="Forward (Alt+→)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
{/if}
{#if $editorDirty} {#if $editorDirty}
<span class="save-indicator">Unsaved</span> <span class="save-indicator">Unsaved</span>
{/if} {/if}
@@ -3530,6 +3746,31 @@
{/if} {/if}
</div> </div>
{#if !$focusMode}
<div class="note-meta-bar">
<span class="note-folder" class:unfiled={!noteFolder}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{#if noteFolder}
{#each noteFolder.split('/') as segment, i}
{#if i > 0}<span class="path-sep"></span>{/if}{segment}
{/each}
{:else}
Unfiled Notes
{/if}
</span>
{#if $activeNote.meta.tags?.length > 0}
<span class="meta-divider">·</span>
{/if}
{#if $activeNote.meta.tags?.length > 0}
<span class="note-tags">
{#each $activeNote.meta.tags as tag}
<span class="note-tag">#{tag}</span>
{/each}
</span>
{/if}
</div>
{/if}
<div class="editor-body-wrapper"> <div class="editor-body-wrapper">
{#if noteSearchOpen} {#if noteSearchOpen}
<div class="note-search-bar"> <div class="note-search-bar">
@@ -3763,7 +4004,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if historySelected && historyPreview !== null} {#if historySelected}
<div class="history-actions"> <div class="history-actions">
<button class="history-restore-btn" onclick={restoreVersion}> <button class="history-restore-btn" onclick={restoreVersion}>
Restore this version Restore this version
@@ -3955,7 +4196,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 5H3"/><path d="M21 12H8"/><path d="M21 19H8"/><path d="M3 12v7"/></svg>
Quote Quote
</button> </button>
<button onclick={() => { insertDropdown = false; editor?.chain().focus().setDetails().run(); }}> <button onclick={() => { insertDropdown = false; insertDetails(); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section Collapsible Section
</button> </button>
@@ -4064,7 +4305,7 @@
</button> </button>
<!-- Collapsible Section --> <!-- Collapsible Section -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => editor?.chain().focus().setDetails().run()} title="Collapsible Section"> <button class="fmt-btn" class:active={(editorState, editor.isActive('details'))} onclick={() => insertDetails()} title="Collapsible Section">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
</button> </button>
@@ -4489,7 +4730,7 @@
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button> <button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button> <button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button> <button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
{#if !isMobile} {#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
<span class="img-toolbar-sep"></span> <span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image"> <button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
@@ -4603,8 +4844,8 @@
<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="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg> <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="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="wiki-link-title-col"> <span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span> <span class="wiki-link-title">{entry.title}</span>
{#if wikiLinkDuplicateTitles.has(entry.title.toLowerCase())} {#if wikiLinkFolderPath(entry)}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span> <span class="wiki-link-folder">{wikiLinkFolderPath(entry)}</span>
{/if} {/if}
</span> </span>
</button> </button>
@@ -4893,6 +5134,40 @@
flex-shrink: 0; flex-shrink: 0;
} }
.nav-history-btns {
display: flex;
align-items: center;
gap: 2px;
margin-right: auto;
flex-shrink: 0;
}
.nav-history-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: none;
color: var(--text-tertiary);
border-radius: 5px;
cursor: pointer;
padding: 0;
transition: color 0.15s, background 0.15s;
}
.nav-history-btn:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.nav-history-btn:disabled {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
.editor-title { .editor-title {
flex: 1; flex: 1;
} }
@@ -4914,6 +5189,57 @@
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.note-meta-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
padding: 0 20px 10px;
flex-shrink: 0;
}
.note-folder {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-tertiary);
letter-spacing: 0.01em;
}
.note-folder.unfiled {
opacity: 0.6;
font-style: italic;
}
.path-sep {
font-size: 10px;
opacity: 0.5;
}
.meta-divider {
font-size: 11px;
color: var(--text-tertiary);
opacity: 0.4;
user-select: none;
}
.note-tags {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
}
.note-tag {
font-size: 11px;
color: var(--text-tertiary);
background: var(--bg-tertiary);
padding: 1px 7px;
border-radius: 10px;
letter-spacing: 0.01em;
}
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -5183,11 +5509,25 @@
.editor-body { .editor-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-anchor: none;
padding: 8px 20px; padding: 8px 20px;
min-width: 0; min-width: 0;
position: relative; position: relative;
} }
.editor-body::-webkit-scrollbar {
width: 8px;
}
.editor-body::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.editor-body::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.editor-body:has(.source-editor) { .editor-body:has(.source-editor) {
overflow: hidden; overflow: hidden;
} }
@@ -5723,6 +6063,12 @@
transform: rotate(90deg); transform: rotate(90deg);
} }
@starting-style {
:global(.tiptap-wrapper .tiptap [data-type="details"].is-open > button::after) {
transform: rotate(0deg);
}
}
:global(.tiptap-wrapper .tiptap [data-type="details"] summary) { :global(.tiptap-wrapper .tiptap [data-type="details"] summary) {
padding: 10px 14px 10px 32px; padding: 10px 14px 10px 32px;
font-weight: 600; font-weight: 600;
@@ -5849,7 +6195,10 @@
min-width: 0; min-width: 0;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div) { /* Strike through only the direct paragraph content of a checked task item.
Using > p instead of > div prevents text-decoration from bleeding into
nested task lists, since CSS text-decoration cannot be cancelled by descendants. */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] > div > p) {
text-decoration: line-through; text-decoration: line-through;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
@@ -6096,10 +6445,34 @@
padding-left: 2px; padding-left: 2px;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] .is-empty::before) { /* Suppress placeholder on task list / details containers — overlaps with checkbox / toggle button */
:global(.tiptap-wrapper .tiptap > ul[data-type="taskList"].is-empty::before),
:global(.tiptap-wrapper .tiptap > [data-type="details"].is-empty::before) {
content: none; content: none;
} }
/* Show placeholder on the paragraph inside task item content div */
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li > div > p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
/* Show placeholder on paragraphs inside collapsible section summary and content */
:global(.tiptap-wrapper .tiptap [data-type="detailsSummary"] p.is-empty::before),
:global(.tiptap-wrapper .tiptap [data-type="detailsContent"] p.is-empty::before) {
content: attr(data-placeholder);
color: var(--text-tertiary);
pointer-events: none;
float: left;
height: 0;
padding-left: 2px;
}
.link-context-overlay { .link-context-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -6560,6 +6933,36 @@
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
margin: 0; margin: 0;
} }
:global(.tiptap .page-break) {
position: relative;
margin: 12px 0;
height: 20px;
border: none;
pointer-events: none;
user-select: none;
}
:global(.tiptap .page-break::before) {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 0;
border-top: 2px dashed var(--text-tertiary, #aaa);
opacity: 0.5;
}
:global(.tiptap .page-break::after) {
content: 'Page Break';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg-primary, #fff);
padding: 0 8px;
font-size: 11px;
color: var(--text-tertiary, #aaa);
white-space: nowrap;
}
:global(.tiptap .pdf-embed-mobile) { :global(.tiptap .pdf-embed-mobile) {
margin: 8px 0; margin: 8px 0;
} }
@@ -7165,14 +7568,45 @@
position: relative; position: relative;
} }
.editor-container.mobile .editor-body-row:has(.history-panel) > .editor-body,
.editor-container.mobile .editor-body-row:has(.outline-panel) > .editor-body {
display: none;
}
.editor-container.mobile .history-panel, .editor-container.mobile .history-panel,
.editor-container.mobile .outline-panel { .editor-container.mobile .outline-panel {
position: absolute; position: static;
inset: 0; flex: 1;
min-height: 0;
width: 100% !important; width: 100% !important;
max-width: 100%; max-width: 100%;
border-left: none; border-left: none;
z-index: 10; }
.editor-container.mobile .history-item {
padding: 12px 12px;
min-height: 48px;
}
.editor-container.mobile .history-list {
padding-bottom: 80px;
}
.editor-container.mobile .history-restore-btn {
padding: 14px 16px;
font-size: 15px;
}
.editor-container.mobile .history-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--bg-secondary);
border-top: 1px solid var(--border-light);
z-index: 51;
} }
.editor-container.mobile .note-search-bar { .editor-container.mobile .note-search-bar {
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
@@ -116,22 +116,37 @@
</div> </div>
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4> <h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4> <h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
+371 -5
View File
@@ -24,28 +24,92 @@
saveNote, saveNote,
getTrash, getTrash,
restoreNote, restoreNote,
restoreNotebook,
permanentDelete, permanentDelete,
getQuickAccess, getQuickAccess,
addQuickAccess, addQuickAccess,
removeQuickAccess, removeQuickAccess,
reorderQuickAccess, reorderQuickAccess,
moveNote, moveNote,
getAllTags getAllTags,
createDailyNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false); let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
let trashBusy = $state<string | null>(null);
// Calendar state for daily notes view
let calMonth = $state(new Date().getMonth());
let calYear = $state(new Date().getFullYear());
let dailyDates = $state<Set<string>>(new Set());
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const calDayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
function calDays(): Array<{ day: number; date: string; current: boolean }> {
const first = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
// Monday=0 offset
let startDow = first.getDay() - 1;
if (startDow < 0) startDow = 6;
const cells: Array<{ day: number; date: string; current: boolean }> = [];
for (let i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDay; d++) {
const mm = String(calMonth + 1).padStart(2, '0');
const dd = String(d).padStart(2, '0');
const date = `${calYear}-${mm}-${dd}`;
const now = new Date();
const current = d === now.getDate() && calMonth === now.getMonth() && calYear === now.getFullYear();
cells.push({ day: d, date, current });
}
return cells;
}
function calPrev() {
if (calMonth === 0) { calMonth = 11; calYear--; }
else calMonth--;
}
function calNext() {
if (calMonth === 11) { calMonth = 0; calYear++; }
else calMonth++;
}
function calToday() {
const now = new Date();
calMonth = now.getMonth();
calYear = now.getFullYear();
}
async function handleDayClick(date: string) {
try {
const entry = await createDailyNote(date);
const content = await readNote(entry.path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = entry.path;
onNoteSelected(entry.path, content.content);
if (!dailyDates.has(date)) {
dailyDates = new Set([...dailyDates, date]);
}
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to open daily note:', e);
}
}
/** Derive tags from current $notes store (avoids re-scanning files on mobile) */ /** Derive tags from current $notes store (avoids re-scanning files on mobile) */
function deriveTagsFromNotes() { function deriveTagsFromNotes() {
@@ -167,6 +231,15 @@
if (listContainer) listContainer.scrollTop = 0; if (listContainer) listContainer.scrollTop = 0;
}); });
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver // Track container height via ResizeObserver
$effect(() => { $effect(() => {
if (!listContainer) return; if (!listContainer) return;
@@ -205,6 +278,7 @@
if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook'; if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook';
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -214,6 +288,7 @@
function cacheKey(): string { function cacheKey(): string {
if ($viewMode === 'trash') return 'trash'; if ($viewMode === 'trash') return 'trash';
if ($viewMode === 'daily') return 'daily';
if ($viewMode === 'quickaccess') return 'quickaccess'; if ($viewMode === 'quickaccess') return 'quickaccess';
if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`; if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`;
if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`; if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`;
@@ -234,7 +309,24 @@
try { try {
if ($viewMode === 'trash') { if ($viewMode === 'trash') {
$notes = await getTrash(); const trash = await getTrash();
$notes = trash.notes;
trashNotebooks = trash.notebooks;
} else if ($viewMode === 'daily') {
const vault = $appConfig?.active_vault;
if (vault) {
$notes = await getNotes(`${vault}/Daily`);
// Build set of dates from filenames (YYYY-MM-DD.md)
const dates = new Set<string>();
for (const n of $notes) {
const fname = n.path.split('/').pop() ?? '';
const m = fname.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
if (m) dates.add(m[1]);
}
dailyDates = dates;
} else {
$notes = [];
}
} else if ($viewMode === 'quickaccess') { } else if ($viewMode === 'quickaccess') {
$notes = await getQuickAccess(); $notes = await getQuickAccess();
} else if ($viewMode === 'tag' && $activeTag) { } else if ($viewMode === 'tag' && $activeTag) {
@@ -273,12 +365,21 @@
} }
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'daily') {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
await handleDayClick(date);
calMonth = today.getMonth();
calYear = today.getFullYear();
return;
}
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null; const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
} }
@@ -351,6 +452,32 @@
} }
} }
async function handleRestoreNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await restoreNotebook(nb.path);
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to restore notebook:', e);
} finally {
trashBusy = null;
}
}
async function handleDeleteNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await permanentDelete(nb.path);
noteCache.clear();
trashNotebooks = trashNotebooks.filter(n => n.path !== nb.path);
} catch (e) {
console.error('Failed to delete notebook permanently:', e);
} finally {
trashBusy = null;
}
}
async function handleTogglePin(note: NoteEntry) { async function handleTogglePin(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -811,7 +938,41 @@
{/if} {/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $sortedNotes.length === 0} {#if $viewMode === 'daily'}
<div class="cal">
<div class="cal-nav">
<button class="cal-nav-btn" onclick={calPrev} title="Previous month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="cal-title" onclick={calToday}>{calMonthNames[calMonth]} {calYear}</button>
<button class="cal-nav-btn" onclick={calNext} title="Next month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="cal-grid">
{#each calDayNames as name}
<div class="cal-head">{name}</div>
{/each}
{#each calDays() as cell}
{#if cell.day === 0}
<div class="cal-cell empty"></div>
{:else}
<button
class="cal-cell"
class:today={cell.current}
class:has-note={dailyDates.has(cell.date)}
class:active={$activeNotePath?.endsWith(`${cell.date}.md`) ?? false}
onclick={() => handleDayClick(cell.date)}
>
{cell.day}
</button>
{/if}
{/each}
</div>
</div>
{/if}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
@@ -822,6 +983,37 @@
</div> </div>
{/if} {/if}
{#if $viewMode === 'trash' && trashNotebooks.length > 0}
{#each trashNotebooks as nb (nb.path)}
<div class="note-item trash-notebook" class:busy={trashBusy === nb.path}>
<div class="note-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
{nb.name}
</div>
{#if trashBusy === nb.path}
<div class="note-preview trash-busy">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
Working...
</div>
{:else}
<div class="note-preview">{nb.note_count} {nb.note_count === 1 ? 'note' : 'notes'}</div>
<div class="trash-notebook-actions">
<button class="trash-nb-btn restore" onclick={() => handleRestoreNotebook(nb)} title="Restore notebook">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-11.36L1 10"/></svg>
Restore
</button>
<button class="trash-nb-btn delete" onclick={() => handleDeleteNotebook(nb)} title="Delete permanently">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete
</button>
</div>
{/if}
</div>
{/each}
{/if}
<div style="height: {topPad}px"></div> <div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)} {#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i} {@const noteIndex = startIndex + i}
@@ -1231,6 +1423,19 @@
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
@@ -1871,4 +2076,165 @@
.mobile-create-btn:active { .mobile-create-btn:active {
opacity: 0.8; opacity: 0.8;
} }
.trash-notebook .note-title {
display: flex;
align-items: center;
gap: 6px;
}
.trash-notebook-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
.trash-nb-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: none;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
background: var(--bg-hover);
color: var(--text-secondary);
}
.trash-nb-btn:hover {
background: var(--bg-active);
}
.trash-nb-btn.delete:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.trash-notebook.busy {
opacity: 0.6;
pointer-events: none;
}
.trash-busy {
display: flex;
align-items: center;
gap: 6px;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Calendar */
.cal {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cal-nav-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.cal-nav-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.cal-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
}
.cal-title:hover {
background: var(--bg-hover);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px 2px;
padding: 0 2px;
}
.cal-head {
font-size: 0.65rem;
color: var(--text-secondary);
text-align: center;
padding: 2px 0;
font-weight: 500;
}
.cal-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
margin: 0 auto;
}
.cal-cell.empty {
cursor: default;
}
.cal-cell:not(.empty):hover {
background: var(--bg-hover);
}
.cal-cell.today {
color: var(--accent);
font-weight: 700;
}
.cal-cell.has-note {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text-primary);
font-weight: 600;
}
.cal-cell.has-note:hover {
background: color-mix(in srgb, var(--accent) 25%, transparent);
}
.cal-cell.active {
background: var(--accent);
color: white;
font-weight: 600;
}
.cal-cell.active:hover {
background: var(--accent);
}
</style> </style>
+3 -2
View File
@@ -67,11 +67,12 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
if (mod && e.key === 's') { if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
+1 -1
View File
@@ -4,7 +4,7 @@
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } 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 { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
+58 -4
View File
@@ -15,9 +15,10 @@
notebookIcons, notebookIcons,
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks collapsedNotebooks,
rootNoteCount
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
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';
@@ -49,20 +50,25 @@
} }
} }
export async function refresh() { export async function refresh() {
try { try {
if (isMobile) { if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead) // On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes] = await Promise.all([ const [nbs, icons, qaNotes, rootCount] = await Promise.all([
getNotebooks(), getNotebooks(),
getNotebookIcons(), getNotebookIcons(),
getQuickAccess(), getQuickAccess(),
countRootNotes(),
]); ]);
$notebooks = nbs; $notebooks = nbs;
$notebookIcons = icons; $notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else { } else {
$notebooks = await getNotebooks(); const [nbs, rootCount] = await Promise.all([getNotebooks(), countRootNotes()]);
$notebooks = nbs;
$rootNoteCount = rootCount;
$tags = await getAllTags(); $tags = await getAllTags();
$notebookIcons = await getNotebookIcons(); $notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess(); const qaNotes = await getQuickAccess();
@@ -80,6 +86,19 @@
onViewChanged(); onViewChanged();
} }
function selectUnfiled() {
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
selectAllNotes();
return;
}
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) { function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook // Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
@@ -106,6 +125,13 @@
onViewChanged(); onViewChanged();
} }
function selectDaily() {
$viewMode = 'daily';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -379,6 +405,20 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<button
class="nav-item"
class:active={$viewMode === 'daily'}
onclick={selectDaily}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily Notes</span>
</button>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'trash'} class:active={$viewMode === 'trash'}
@@ -445,6 +485,20 @@
{/if} {/if}
<div class="notebook-list"> <div class="notebook-list">
{#if $rootNoteCount > 0}
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
>
<span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each $notebooks as nb (nb.path)} {#each $notebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active { .switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent); color: var(--accent);
+31 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type {
AppConfig, AppConfig,
NoteEntry, NoteEntry,
@@ -31,6 +31,7 @@ export const collapsedNotebooks = writable<string[]>([]);
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0);
export const notes = writable<NoteEntry[]>([]); export const notes = writable<NoteEntry[]>([]);
export const tags = writable<[string, number][]>([]); export const tags = writable<[string, number][]>([]);
export const activeNote = writable<NoteContent | null>(null); export const activeNote = writable<NoteContent | null>(null);
@@ -105,6 +106,35 @@ export async function checkForUpdateMobile() {
} }
} }
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
+14 -1
View File
@@ -14,6 +14,18 @@ export interface NoteEntry {
preview: string; preview: string;
} }
export interface TrashNotebookEntry {
name: string;
path: string;
note_count: number;
modified: string;
}
export interface TrashContents {
notes: NoteEntry[];
notebooks: TrashNotebookEntry[];
}
export interface NotebookEntry { export interface NotebookEntry {
name: string; name: string;
path: string; path: string;
@@ -131,4 +143,5 @@ export type ViewMode =
| "tag" | "tag"
| "trash" | "trash"
| "search" | "search"
| "quickaccess"; | "quickaccess"
| "daily";
+50 -11
View File
@@ -2,8 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { openFile, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
@@ -33,6 +32,19 @@
return resolved.join('/'); return resolved.join('/');
} }
const isAndroid = /android/i.test(navigator.userAgent);
function openLocalFile(path: string) {
if (isAndroid) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(path);
}
} else {
openFile(path).catch((err) => console.error('Failed to open file:', err));
}
}
function resolveAndHandleLink(href: string) { function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err)); openUrl(href).catch((err) => console.error('Failed to open URL:', err));
@@ -42,12 +54,17 @@
const vaultRoot = config?.active_vault; const vaultRoot = config?.active_vault;
let absPath = decoded; let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) { if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath); // .helixnotes/ paths are always relative to vault root, not the note's directory
if (notePath) { if (decoded.startsWith('.helixnotes/')) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`); absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
} }
} }
// Internal .md note link — navigate within the app // Internal .md note link — navigate within the app
@@ -57,7 +74,7 @@
activeNotePath.set(absPath); activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err)); }).catch((err) => console.error('Failed to navigate to note:', err));
} else { } else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err)); openLocalFile(absPath);
} }
} }
} }
@@ -76,7 +93,17 @@
// Intercept all link clicks in capture phase to prevent webview navigation // Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => { onMount(() => {
function handleLinkClick(e: MouseEvent) { function handleLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest('a'); const el = e.target as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
@@ -97,10 +124,22 @@
touchStartY = e.touches[0]?.clientY ?? 0; touchStartY = e.touches[0]?.clientY ?? 0;
} }
function handleTouchEnd(e: TouchEvent) { function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0; const touch = e.changedTouches[0];
if (!touch) return;
const endY = touch.clientY;
// If the finger moved > 10px, it's a scroll, not a tap // If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return; if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a'); // Android WebView may report e.target as the editor root for elements
// inside contenteditable="false" blocks; use elementFromPoint instead
const el = (document.elementFromPoint(touch.clientX, touch.clientY) ?? e.target) as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
} },
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// To make changes to top-level options such as include and exclude, we recommend extending // To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }