Compare commits

..
11 Commits
18 changed files with 1044 additions and 120 deletions
+16 -26
View File
@@ -5,7 +5,7 @@ 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.1.9) ## Download (v1.2.1)
### Linux ### Linux
@@ -23,23 +23,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.1.9/HelixNotes_1.1.9_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_amd64.deb) Ubuntu 22.04+ [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64-setup.exe) Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.9/HelixNotes_1.1.9_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta) [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)
### Android ### Android
[Download APK](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_android.apk) — Tested on Android 16 [Download APK](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_android.apk)
--- ---
@@ -47,26 +47,16 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features ## Features
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting - Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Wiki-links** — link notes with `[[Note Title]]` syntax - `[[Wiki-links]]` and graph view
- **Graph view** — visualize connections between your notes - Full-text search (Tantivy)
- **Outline panel** — heading navigation for long notes - Outline panel, daily notes, tags, drag-and-drop
- **Full-text search** powered by Tantivy - Math (KaTeX), PDF preview, Obsidian import
- **Math support** — KaTeX rendering for inline and block equations - AI writing tools (Ollama / Anthropic / OpenAI)
- **Daily notes** — one-click button to create or open today's note - Version history with diffs, automatic backups
- **AI writing tools** — improve, summarize, translate, and more (Ollama / Anthropic / OpenAI) - Multi-window, file associations, focus mode, view mode
- **Version history** — per-note snapshots with diff view - Themes, accent colors, fonts
- **Backups** — automatic zip-based vault backups - Local files, no cloud
- **PDF preview** — inline rendering of embedded PDFs
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Multi-window** — open notes in separate windows (right-click → "Open in New Window")
- **File associations** — open .md files from your file manager directly in HelixNotes
- **Themes** — light/dark mode with customizable accent colors, fonts, and line height
- **Focus mode** — distraction-free writing
- **View mode** — read-only toggle for distraction-free reading
- **Local** — everything stays on your machine
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html) Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -0,0 +1,3 @@
- Internal note links ([[wiki-links]] open the linked note)
- Mobile UI improvements
- Window state persistence (desktop)
@@ -0,0 +1,12 @@
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
Features:
- Markdown editor with toolbar, slash commands, and code highlighting
- [[Wiki-links]] and graph view
- Full-text search
- Outline panel, daily notes, tags
- Math (KaTeX) and PDF preview
- AI writing tools (Ollama, Anthropic, OpenAI)
- Version history with diffs
- Obsidian import
- Themes, accent colors, fonts
@@ -0,0 +1 @@
Local markdown note-taking app. No cloud, no lock-in.
@@ -0,0 +1 @@
HelixNotes
+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.0", "version": "1.2.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+35 -2
View File
@@ -633,6 +633,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"pure-rust-locales",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
@@ -1849,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.0" version = "1.2.3"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1867,6 +1868,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sys-locale",
"tantivy", "tantivy",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1876,6 +1878,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio", "tokio",
"uuid", "uuid",
"walkdir", "walkdir",
@@ -3141,7 +3144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.45.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3588,6 +3591,12 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -4769,6 +4778,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -5262,6 +5280,21 @@ dependencies = [
"zip 4.6.1", "zip 4.6.1",
] ]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.0" version = "2.10.0"
+4 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.0" version = "1.2.3"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -26,7 +26,8 @@ serde_yaml = "0.9"
log = "0.4" log = "0.4"
notify = "8" notify = "8"
walkdir = "2" walkdir = "2"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sys-locale = "0.3"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
tantivy = "0.22" tantivy = "0.22"
gray_matter = "0.2" gray_matter = "0.2"
@@ -41,6 +42,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] } arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17" png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+2 -1
View File
@@ -5,6 +5,7 @@
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"updater:default" "updater:default",
"window-state:default"
] ]
} }
+5 -2
View File
@@ -287,8 +287,11 @@ pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String
} }
#[tauri::command] #[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> { pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title) let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
operations::rename_note(&path, &new_title, &vault_path)
} }
#[tauri::command] #[tauri::command]
+1
View File
@@ -180,6 +180,7 @@ pub fn run() {
})); }));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| { builder = builder.on_window_event(move |window, event| {
match event { match event {
+192 -7
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Utc}; use chrono::{Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -276,7 +276,7 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, _content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings) // Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) { if let Ok(fs_meta) = fs::metadata(path) {
@@ -294,11 +294,13 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry { Ok(NoteEntry {
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
meta, meta,
preview: String::new(), preview,
}) })
} }
@@ -449,10 +451,62 @@ pub fn create_note(
}) })
} }
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
let today = Local::now(); let today = Local::now();
let date_str = today.format("%Y-%m-%d").to_string(); let date_str = today.format("%Y-%m-%d").to_string();
let title = today.format("%B %d, %Y").to_string(); let locale = get_system_locale();
let title = today.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())?;
@@ -571,13 +625,13 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
Ok(()) Ok(())
} }
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let src = Path::new(path);
if !src.exists() { if !src.exists() {
return Err("Note does not exist".to_string()); return Err("Note does not exist".to_string());
} }
// Update frontmatter // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src let filename = src
.file_name() .file_name()
@@ -585,6 +639,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
let old_title = meta.title.clone();
let old_path_str = src.to_string_lossy().to_string();
// Update frontmatter
meta.title = new_title.to_string(); meta.title = new_title.to_string();
meta.modified = Utc::now(); meta.modified = Utc::now();
if meta.id.is_empty() { if meta.id.is_empty() {
@@ -601,7 +659,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
fs::rename(src, &new_path).map_err(|e| e.to_string())?; fs::rename(src, &new_path).map_err(|e| e.to_string())?;
} }
Ok(new_path.to_string_lossy().to_string()) let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
Ok(new_path_str)
}
/// Walk all .md files in the vault and update wikilink references after a note rename.
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
fn update_wikilinks_after_rename(
vault_path: &str,
old_path: &str,
new_path: &str,
old_title: &str,
new_title: &str,
) {
let vault = Path::new(vault_path);
let vault_prefix = format!("{}/", vault_path);
// Compute vault-relative path refs (without .md) for path-based wikilink refs
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
.unwrap_or(old_path)
.strip_suffix(".md")
.unwrap_or(old_path);
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
.unwrap_or(new_path)
.strip_suffix(".md")
.unwrap_or(new_path);
// Check if another note with the same old_title exists in the vault.
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
// because we can't tell which [[Old Title]] ref points to the renamed note
// vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
return false;
}
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
let p_str = p.to_string_lossy();
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(old_title))
.unwrap_or(false)
});
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut result = content.clone();
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
// Update all wikilink forms that reference the renamed note:
// Title-based rules only apply when the old title is unique in the vault.
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
}
// Path-based rules are always safe (paths are unique).
if old_rel_ref != new_rel_ref {
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
result = result.replace(
&format!("[[{}]]", old_rel_ref),
&format!("[[{}]]", new_rel_ref),
);
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
// Also update the alias if it matches the old title.
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
if old_title != new_title {
result = result.replace(
&format!("[[{}|{}]]", old_rel_ref, old_title),
&format!("[[{}|{}]]", new_rel_ref, new_title),
);
}
// For aliases that DON'T match the old title, just update the ref part
result = result.replace(
&format!("[[{}|", old_rel_ref),
&format!("[[{}|", new_rel_ref),
);
}
if result != content {
let _ = fs::write(path, &result);
}
}
} }
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> { pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
+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.0", "version": "1.2.3",
"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 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 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/**"]
+91
View File
@@ -179,3 +179,94 @@ body.resizing {
display: none !important; display: none !important;
} }
} }
/* ── Print / Export PDF ── */
@media print {
/* Hide everything except editor content */
.titlebar,
.sidebar-panel,
.notelist-panel,
.resize-handle,
.toolbar-actions,
.editor-body-wrapper .note-search-bar,
.formatting-toolbar,
.editor-formatting-bar,
.history-panel,
.outline-panel,
.graph-panel,
.ai-panel,
.link-context-menu,
.image-toolbar,
.selection-bar {
display: none !important;
}
/* Force light theme colors for PDF */
:root, :root.dark {
--bg-primary: #ffffff !important;
--bg-secondary: #f8f9fa !important;
--bg-tertiary: #f0f1f3 !important;
--bg-editor: #ffffff !important;
--text-primary: #1a1a2e !important;
--text-secondary: #495057 !important;
--text-tertiary: #868e96 !important;
--text-accent: #5b6abf !important;
--accent: #5b6abf !important;
--border-color: #e2e5e9 !important;
--border-light: #f0f1f3 !important;
}
html, body {
height: auto;
overflow: visible;
background: white !important;
color: #1a1a2e !important;
font-size: 12pt;
padding: 0;
}
/* Override all scoped height/overflow constraints for print flow */
* {
overflow: visible !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
}
.app-layout {
display: block !important;
}
.editor-panel {
width: 100% !important;
min-width: 0 !important;
}
.ProseMirror {
padding: 0 !important;
max-width: 100% !important;
background: white !important;
color: #1a1a2e !important;
}
/* Ensure images fit within page */
.ProseMirror img {
max-width: 100% !important;
page-break-inside: avoid;
}
/* Avoid breaking inside these elements */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
pre, blockquote, table, figure {
page-break-inside: avoid;
}
/* Code blocks with light background */
.ProseMirror pre, .ProseMirror code {
background: #f8f9fa !important;
color: #1a1a2e !important;
}
}
+60 -22
View File
@@ -192,34 +192,49 @@
} }
// Android back gesture / hardware back button support // Android back gesture / hardware back button support
let mobileNavFromPopstate = false; // We maintain a simple counter of how many views deep we are.
// sidebar=0, notelist=1, editor=2. Each forward nav pushes, back pops.
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) { if (isMobile) {
// Seed initial history state history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
history.replaceState({ mobileView: 'sidebar' }, '');
// When mobileView changes forward, push browser history so Android back gesture works
$effect(() => { $effect(() => {
const view = $mobileView; const view = $mobileView;
if (mobileNavFromPopstate) { if (navFromPopstate) {
mobileNavFromPopstate = false; navFromPopstate = false;
return; return;
} }
// Replace state to track current view const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
history.pushState({ mobileView: view }, ''); if (targetDepth > historyDepth) {
// Forward navigation — push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, '');
}
historyDepth = targetDepth;
} else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth;
historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers
history.go(-steps);
}
}); });
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
const currentView = $mobileView; if (navFromPopstate) {
if (currentView === 'sidebar') { navFromPopstate = false;
// Already at root — let Android handle it (exit app)
return; return;
} }
mobileNavFromPopstate = true; const state = e.state;
if (currentView === 'editor') $mobileView = 'notelist'; const targetDepth = state?.depth ?? 0;
else if (currentView === 'notelist') $mobileView = 'sidebar'; historyDepth = targetDepth;
// Push state again so next back gesture also works navFromPopstate = true;
history.pushState({ mobileView: $mobileView }, ''); if (targetDepth === 0) $mobileView = 'sidebar';
else if (targetDepth === 1) $mobileView = 'notelist';
else $mobileView = 'editor';
}); });
} }
@@ -525,12 +540,7 @@
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</button> </button>
<button class="mobile-header-btn" onclick={createAndFocusNote} title="New Note"> <button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
@@ -555,6 +565,11 @@
</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()} />
<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">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
@@ -831,4 +846,27 @@
pointer-events: auto; pointer-events: auto;
} }
.mobile-fab {
position: absolute;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer;
z-index: 10;
}
.mobile-fab:active {
transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style> </style>
+363 -29
View File
@@ -199,6 +199,13 @@
let linkModalInput = $state<HTMLInputElement>(null!); let linkModalInput = $state<HTMLInputElement>(null!);
let linkSelectionFrom = 0; let linkSelectionFrom = 0;
let linkSelectionTo = 0; let linkSelectionTo = 0;
let linkSuggestIndex = $state(0);
let linkSuggestTitles = $state<NoteTitleEntry[]>([]);
let linkSuggestFiltered = $derived.by(() => {
const q = linkModalUrl.trim().toLowerCase();
if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return [];
return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8);
});
let textContextMenu = $state<{ x: number; y: number } | null>(null); let textContextMenu = $state<{ x: number; y: number } | null>(null);
let tableContextMenu = $state<{ x: number; y: number } | null>(null); let tableContextMenu = $state<{ x: number; y: number } | null>(null);
let tablePickerOpen = $state(false); let tablePickerOpen = $state(false);
@@ -735,8 +742,17 @@
let wikiLinkSelectedIndex = $state(0); let wikiLinkSelectedIndex = $state(0);
let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]); let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]);
let wikiLinkTypedByUser = false; let wikiLinkTypedByUser = false;
// Disambiguation state: when ]] auto-close finds multiple matches
let wikiLinkDisambigEntries = $state<NoteTitleEntry[] | null>(null);
let wikiLinkDisambigRef = $state<string | null>(null);
let wikiLinkDisambigDisplay = $state<string | null>(null);
// Navigation disambiguation: when clicking a wikilink with multiple matches
let wikiLinkNavDisambig = $state<{ entries: NoteTitleEntry[]; x: number; y: number } | null>(null);
let wikiLinkNavDisambigIndex = $state(0);
let wikiLinkFiltered = $derived.by(() => { let wikiLinkFiltered = $derived.by(() => {
// When disambiguating, show only the exact matches
if (wikiLinkDisambigEntries) return wikiLinkDisambigEntries;
if (!wikiLinkMenu) return wikiLinkTitlesCache; if (!wikiLinkMenu) return wikiLinkTitlesCache;
let q = wikiLinkMenu.query.toLowerCase(); let q = wikiLinkMenu.query.toLowerCase();
if (!q) return wikiLinkTitlesCache; if (!q) return wikiLinkTitlesCache;
@@ -750,6 +766,29 @@
); );
}); });
// Set of lowercase titles that appear more than once (for disambiguation display)
let wikiLinkDuplicateTitles = $derived.by(() => {
const counts = new Map<string, number>();
for (const e of wikiLinkTitlesCache) {
const key = e.title.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const dupes = new Set<string>();
for (const [key, count] of counts) {
if (count > 1) dupes.add(key);
}
return dupes;
});
function wikiLinkFolderPath(entry: NoteTitleEntry): string {
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot || !entry.path) return '';
const rel = entry.path.startsWith(vaultRoot + '/') ? entry.path.slice(vaultRoot.length + 1) : entry.path;
const parts = rel.split('/');
// Return parent folder(s), excluding the filename
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
}
async function refreshWikiLinkTitles() { async function refreshWikiLinkTitles() {
try { try {
wikiLinkTitlesCache = await getAllNoteTitles(); wikiLinkTitlesCache = await getAllNoteTitles();
@@ -761,6 +800,15 @@
function closeWikiLinkMenu() { function closeWikiLinkMenu() {
wikiLinkMenu = null; wikiLinkMenu = null;
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
wikiLinkDisambigEntries = null;
wikiLinkDisambigRef = null;
wikiLinkDisambigDisplay = null;
}
function wikiLinkRelPath(entry: NoteTitleEntry): string | null {
const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot || !entry.path || !entry.path.startsWith(vaultRoot + '/')) return null;
return entry.path.slice(vaultRoot.length + 1).replace(/\.md$/, '');
} }
function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) { function insertWikiLink(entry: NoteTitleEntry, originalRef?: string) {
@@ -770,9 +818,17 @@
const to = editor.state.selection.from; const to = editor.state.selection.from;
editor.chain().focus().deleteRange({ from, to }).run(); editor.chain().focus().deleteRange({ from, to }).run();
// Insert the wiki-link mark // Insert the wiki-link mark
// entry.title is the display text, originalRef (if provided) is the full reference (e.g. "note#heading") // For ambiguous titles, use vault-relative path as the ref so it survives source-mode roundtrips
const displayText = entry.title; const displayText = entry.title;
const titleAttr = originalRef || entry.title; let titleAttr = originalRef || entry.title;
if (entry.path && wikiLinkDuplicateTitles.has(entry.title.toLowerCase())) {
const relPath = wikiLinkRelPath(entry);
if (relPath) {
// Preserve any #heading or ^block anchors from the original ref
const anchor = originalRef ? originalRef.replace(/^[^#^]*/, '') : '';
titleAttr = relPath + anchor;
}
}
tick().then(() => { tick().then(() => {
if (!editor) return; if (!editor) return;
editor.chain().focus() editor.chain().focus()
@@ -789,7 +845,12 @@
function executeWikiLinkCommand(index: number) { function executeWikiLinkCommand(index: number) {
const items = wikiLinkFiltered; const items = wikiLinkFiltered;
if (index < 0 || index >= items.length) return; if (index < 0 || index >= items.length) return;
insertWikiLink(items[index]); if (wikiLinkDisambigEntries) {
// In disambiguation mode: use stored display/ref
insertWikiLink({ ...items[index], title: wikiLinkDisambigDisplay || items[index].title }, wikiLinkDisambigRef || undefined);
} else {
insertWikiLink(items[index]);
}
} }
const WikiLink = TiptapMark.create({ const WikiLink = TiptapMark.create({
@@ -842,7 +903,7 @@
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title); navigateToWikiLink(path, title, event as MouseEvent);
return true; return true;
} }
return false; return false;
@@ -910,9 +971,18 @@
const display = (pipeIdx >= 0 ? rawQuery.slice(pipeIdx + 1) : noteRef).trim(); const display = (pipeIdx >= 0 ? rawQuery.slice(pipeIdx + 1) : noteRef).trim();
// Strip #heading and ^block for title matching // Strip #heading and ^block for title matching
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === titleForLookup.toLowerCase()); const matches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === titleForLookup.toLowerCase());
if (match) { if (matches.length === 1) {
insertWikiLink({ ...match, title: display }, noteRef); insertWikiLink({ ...matches[0], title: display }, noteRef);
} else if (matches.length > 1) {
// Multiple matches — show disambiguation picker
// Keep the menu open but filter to only the matching entries
wikiLinkMenu = { ...wikiLinkMenu!, query: titleForLookup };
// Override filtered results to only show exact matches
wikiLinkDisambigEntries = matches;
wikiLinkDisambigRef = noteRef;
wikiLinkDisambigDisplay = display;
wikiLinkSelectedIndex = 0;
} else { } else {
// Insert as unresolved wiki-link (no path) // Insert as unresolved wiki-link (no path)
const menuFrom = wikiLinkMenu.from; const menuFrom = wikiLinkMenu.from;
@@ -984,22 +1054,57 @@
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
} }
async function navigateToWikiLink(path: string, title: string) { async function navigateToWikiLink(path: string, title: string, clickEvent?: MouseEvent) {
// title may contain #heading or ^block anchors — strip for note lookup // title may contain #heading or ^block anchors — strip for note lookup
const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const noteTitle = title.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
if (!path) {
// Try path-based resolution first (for disambiguated refs like "folder/note")
const vaultRoot = $appConfig?.active_vault;
if (noteTitle.includes('/') && vaultRoot) {
const fullPath = vaultRoot + '/' + noteTitle + '.md';
const pathMatch = wikiLinkTitlesCache.find(e => e.path === fullPath);
if (pathMatch) {
path = pathMatch.path;
} else {
// Path not found — try title of last segment
const lastSegment = noteTitle.split('/').pop()!;
const segMatches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === lastSegment.toLowerCase());
if (segMatches.length === 1) path = segMatches[0].path;
else if (segMatches.length > 1) {
let x = clickEvent ? clickEvent.clientX : window.innerWidth / 2 - 140;
let y = clickEvent ? clickEvent.clientY + 8 : window.innerHeight / 2 - 100;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 200 > window.innerHeight) y = Math.max(4, window.innerHeight - 200);
wikiLinkNavDisambig = { entries: segMatches, x, y };
wikiLinkNavDisambigIndex = 0;
return;
}
}
}
}
if (!path) { if (!path) {
// Unresolved link — try to find by title // Unresolved link — try to find by title
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === noteTitle.toLowerCase()); const matches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === noteTitle.toLowerCase());
if (match) { if (matches.length === 1) {
path = match.path; path = matches[0].path;
} else if (matches.length > 1) {
// Multiple matches — show disambiguation popup
let x = clickEvent ? clickEvent.clientX : window.innerWidth / 2 - 140;
let y = clickEvent ? clickEvent.clientY + 8 : window.innerHeight / 2 - 100;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 200 > window.innerHeight) y = Math.max(4, window.innerHeight - 200);
wikiLinkNavDisambig = { entries: matches, x, y };
wikiLinkNavDisambigIndex = 0;
return;
} else { } else {
// Offer to create the note (use clean title, not the anchor ref) // Offer to create the note (use clean title, not the anchor ref)
const cleanTitle = noteTitle.includes('/') ? noteTitle.split('/').pop()! : noteTitle;
const notebookRel = $activeNotePath const notebookRel = $activeNotePath
? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/') ? $activeNotePath.substring(($appConfig?.active_vault?.length ?? 0) + 1).split('/').slice(0, -1).join('/')
: null; : null;
try { try {
const { createNote } = await import('$lib/api'); const { createNote } = await import('$lib/api');
const newNote = await createNote(notebookRel || null, noteTitle); const newNote = await createNote(notebookRel || null, cleanTitle);
// Navigate to the new note // Navigate to the new note
const content = await readNote(newNote.path); const content = await readNote(newNote.path);
$activeNote = { ...content, content: content.content }; $activeNote = { ...content, content: content.content };
@@ -1019,6 +1124,17 @@
} }
} }
async function navigateToWikiLinkDirect(entry: NoteTitleEntry) {
wikiLinkNavDisambig = null;
try {
const content = await readNote(entry.path);
$activeNote = { ...content, content: content.content };
$activeNotePath = entry.path;
} catch (e) {
console.error('Failed to navigate to wiki-link:', e);
}
}
const textColors = [ const textColors = [
{ name: 'Default', value: '' }, { name: 'Default', value: '' },
{ name: 'Red', value: '#ef4444' }, { name: 'Red', value: '#ef4444' },
@@ -1823,7 +1939,12 @@
.replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n') .replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n')
.replace(/<br\s*\/?>/gi, '\n> '); .replace(/<br\s*\/?>/gi, '\n> ');
}); });
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, (_m, href, text) => {
// Decode percent-encoded href back to readable form for markdown source
// Spaces are re-encoded by markdownToHtml preprocessing before markdown-it parsing
const decoded = decodeURIComponent(href);
return `[${text}](${decoded})`;
});
md = md.replace(/<img[^>]*>/gi, (match) => { md = md.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src="([^"]*)"/); const srcMatch = match.match(/src="([^"]*)"/);
const altMatch = match.match(/alt="([^"]*)"/); const altMatch = match.match(/alt="([^"]*)"/);
@@ -1905,8 +2026,28 @@
const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim(); const display = (pipeIdx >= 0 ? raw.slice(pipeIdx + 1) : noteRef).trim();
// Strip #heading and ^block anchors for title matching // Strip #heading and ^block anchors for title matching
const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim(); const titleForLookup = noteRef.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
// Try to resolve the title to a path from cache // Try to resolve: first by vault-relative path (for disambiguated links), then by title
const match = wikiLinkTitlesCache.find(e => e.title.toLowerCase() === titleForLookup.toLowerCase()); const vaultRoot = $appConfig?.active_vault ?? '';
let match: NoteTitleEntry | undefined;
if (titleForLookup.includes('/') && vaultRoot) {
// Ref contains a path — resolve by matching vault-relative path
const fullPath = vaultRoot + '/' + titleForLookup + '.md';
match = wikiLinkTitlesCache.find(e => e.path === fullPath);
}
if (!match) {
// Fallback: resolve by title (use the last segment if path-based)
const titleOnly = titleForLookup.includes('/') ? titleForLookup.split('/').pop()! : titleForLookup;
const titleLower = titleOnly.toLowerCase();
const titleMatches = wikiLinkTitlesCache.filter(e => e.title.toLowerCase() === titleLower);
if (titleMatches.length === 1) {
match = titleMatches[0];
} else if (titleMatches.length > 1) {
// Multiple matches — prefer the shallowest path (closest to vault root)
match = titleMatches.reduce((a, b) =>
a.path.split('/').length <= b.path.split('/').length ? a : b
);
}
}
const path = match ? match.path : ''; const path = match ? match.path : '';
return `<span data-wiki-link data-path="${escapeHtml(path)}" data-title="${escapeHtml(noteRef)}" class="wiki-link">${escapeHtml(display)}</span>`; return `<span data-wiki-link data-path="${escapeHtml(path)}" data-title="${escapeHtml(noteRef)}" class="wiki-link">${escapeHtml(display)}</span>`;
}); });
@@ -1920,6 +2061,12 @@
return `![${alt}](${url.replace(/ /g, '%20')})`; return `![${alt}](${url.replace(/ /g, '%20')})`;
}); });
// Pre-process: percent-encode spaces in link URLs so markdown-it parses them correctly
// Matches [text](url with spaces) but not ![image](url) (already handled above)
src = src.replace(/(?<!!)\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (_match, text, url) => {
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 on desktop, clickable links on mobile
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 ?? '';
@@ -2232,8 +2379,11 @@
linkSelectionFrom = from; linkSelectionFrom = from;
linkSelectionTo = to; linkSelectionTo = to;
const previousUrl = editor.getAttributes('link').href || ''; const previousUrl = editor.getAttributes('link').href || '';
linkModalUrl = previousUrl; linkModalUrl = decodeURIComponent(previousUrl);
linkSuggestIndex = 0;
linkModal = true; linkModal = true;
// Load note titles for autocomplete
getAllNoteTitles().then(t => { linkSuggestTitles = t; }).catch(() => {});
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -2243,15 +2393,37 @@
if (url === '') { if (url === '') {
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run(); editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run();
} else { } else {
if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#')) { if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#') && !url.endsWith('.md')) {
url = 'https://' + url; url = 'https://' + url;
} }
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href: url }).run(); // Store raw URL — encoding is handled during markdown serialization/parsing
const href = url.replace(/[()]/g, (c) => encodeURIComponent(c));
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run();
} }
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
} }
function linkModalSelectNote(entry: NoteTitleEntry) {
// Build a relative .md path from the selected note and confirm immediately
const vaultRoot = $appConfig?.active_vault;
const currentNote = $activeNotePath;
if (vaultRoot && currentNote) {
const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/'));
const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path;
const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir;
const targetParts = targetRel.split('/');
const currentParts = currentRel ? currentRel.split('/') : [];
let common = 0;
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
const ups = currentParts.length - common;
linkModalUrl = (ups > 0 ? '../'.repeat(ups) : './') + targetParts.slice(common).join('/');
} else {
linkModalUrl = entry.title + '.md';
}
linkModalConfirm();
}
function linkModalCancel() { function linkModalCancel() {
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
@@ -2296,7 +2468,7 @@
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || ''; const title = wikiLinkEl.getAttribute('data-title') || wikiLinkEl.textContent || '';
navigateToWikiLink(path, title); navigateToWikiLink(path, title, event);
return; return;
} }
@@ -2793,19 +2965,43 @@
linkContextMenu = null; linkContextMenu = null;
} }
/** Resolve a link href to an absolute .md note path, or null if not a note link. */
function resolveNoteHref(href: string): string | null {
const decoded = decodeURIComponent(href);
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null;
let absPath = decoded;
if (!decoded.startsWith('/')) {
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
return absPath.endsWith('.md') ? absPath : null;
}
function linkMenuOpen() { function linkMenuOpen() {
if (!linkContextMenu) return; if (!linkContextMenu) return;
const href = linkContextMenu.href; const href = linkContextMenu.href;
closeLinkContextMenu(); closeLinkContextMenu();
// Internal .md note link — navigate within the app
const notePath = resolveNoteHref(href);
if (notePath) {
navigateToWikiLink(notePath, '');
return;
}
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); const decoded = decodeURIComponent(href);
let absPath = decoded; let absPath = decoded;
if (!decoded.startsWith('/')) { if (!decoded.startsWith('/')) {
const notePath = $activeNotePath; const np = $activeNotePath;
if (notePath) { if (np) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); const noteDir = np.substring(0, np.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`); absPath = normalizePath(`${noteDir}/${decoded}`);
} else { } else {
const vaultRoot = $appConfig?.active_vault; const vaultRoot = $appConfig?.active_vault;
@@ -2832,7 +3028,7 @@
if (pos >= 0) { if (pos >= 0) {
editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run(); editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run();
} }
linkModalUrl = href; linkModalUrl = decodeURIComponent(href);
linkModal = true; linkModal = true;
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -3199,6 +3395,8 @@
? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } } ? { ...n, path: newPath, relative_path: n.relative_path.replace(/[^/]+$/, newTitle + '.md'), meta: { ...n.meta, title: newTitle } }
: n : n
)); ));
// Refresh wiki-link titles cache so links resolve to renamed note
refreshWikiLinkTitles();
} catch (err) { } catch (err) {
console.error('Failed to rename note file:', err); console.error('Failed to rename note file:', err);
notes.update(list => list.map(n => notes.update(list => list.map(n =>
@@ -4400,10 +4598,15 @@
class="wiki-link-item" class="wiki-link-item"
class:selected={i === wikiLinkSelectedIndex} class:selected={i === wikiLinkSelectedIndex}
onmouseenter={() => wikiLinkSelectedIndex = i} onmouseenter={() => wikiLinkSelectedIndex = i}
onmousedown={(e) => { e.preventDefault(); insertWikiLink(entry); }} onmousedown={(e) => { e.preventDefault(); if (wikiLinkDisambigEntries) { insertWikiLink({ ...entry, title: wikiLinkDisambigDisplay || entry.title }, wikiLinkDisambigRef || undefined); } else { insertWikiLink(entry); } }}
> >
<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">{entry.title}</span> <span class="wiki-link-title-col">
<span class="wiki-link-title">{entry.title}</span>
{#if wikiLinkDuplicateTitles.has(entry.title.toLowerCase())}
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span>
{/if}
</span>
</button> </button>
{/each} {/each}
{/if} {/if}
@@ -4411,6 +4614,30 @@
</div> </div>
{/if} {/if}
{#if wikiLinkNavDisambig}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-overlay" onclick={() => wikiLinkNavDisambig = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="wiki-link-menu" style="left: {wikiLinkNavDisambig.x}px; top: {wikiLinkNavDisambig.y}px" onclick={(e) => e.stopPropagation()}>
<div class="wiki-link-disambig-header">Multiple notes found — choose one:</div>
{#each wikiLinkNavDisambig.entries as entry, i}
<button
class="wiki-link-item"
class:selected={i === wikiLinkNavDisambigIndex}
onmouseenter={() => wikiLinkNavDisambigIndex = i}
onmousedown={(e) => { e.preventDefault(); navigateToWikiLinkDirect(entry); }}
>
<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">{entry.title}</span>
<span class="wiki-link-folder">{wikiLinkFolderPath(entry) || '(vault root)'}</span>
</span>
</button>
{/each}
</div>
</div>
{/if}
{#if aiMenu} {#if aiMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}> <div class="ai-menu-overlay" class:mobile={isMobile} onclick={closeAiMenu}>
@@ -4574,12 +4801,34 @@
class="link-modal-input" class="link-modal-input"
bind:this={linkModalInput} bind:this={linkModalInput}
bind:value={linkModalUrl} bind:value={linkModalUrl}
oninput={() => { linkSuggestIndex = 0; }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); } if (linkSuggestFiltered.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); linkSuggestIndex = Math.min(linkSuggestIndex + 1, linkSuggestFiltered.length - 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); linkSuggestIndex = Math.max(linkSuggestIndex - 1, 0); return; }
if (e.key === 'Enter') { e.preventDefault(); linkModalSelectNote(linkSuggestFiltered[linkSuggestIndex]); return; }
} else {
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); }
}
if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); } if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); }
}} }}
placeholder="https://example.com" placeholder="URL or note name"
/> />
{#if linkSuggestFiltered.length > 0}
<div class="link-suggest-list">
{#each linkSuggestFiltered as entry, i}
<button
class="link-suggest-item"
class:selected={i === linkSuggestIndex}
onmouseenter={() => linkSuggestIndex = i}
onmousedown={(e) => { e.preventDefault(); linkModalSelectNote(entry); }}
>
<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="link-suggest-title">{entry.title}</span>
</button>
{/each}
</div>
{/if}
<div class="link-modal-actions"> <div class="link-modal-actions">
<button class="link-modal-btn cancel" onclick={linkModalCancel}>Cancel</button> <button class="link-modal-btn cancel" onclick={linkModalCancel}>Cancel</button>
<button class="link-modal-btn confirm" onclick={linkModalConfirm}> <button class="link-modal-btn confirm" onclick={linkModalConfirm}>
@@ -5159,7 +5408,7 @@
color: var(--text-primary); color: var(--text-primary);
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: var(--editor-line-height, 1.6); line-height: 1.3;
resize: none; resize: none;
outline: none; outline: none;
padding: 0; padding: 0;
@@ -5187,7 +5436,7 @@
padding-top: 8px; padding-top: 8px;
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: var(--editor-font-size, 14px); font-size: var(--editor-font-size, 14px);
line-height: var(--editor-line-height, 1.6); line-height: 1.3;
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.5; opacity: 0.5;
text-align: right; text-align: right;
@@ -5617,10 +5866,27 @@
text-decoration-color: color-mix(in srgb, var(--text-accent) 40%, transparent); text-decoration-color: color-mix(in srgb, var(--text-accent) 40%, transparent);
} }
:global(.tiptap-wrapper .tiptap a::after) {
content: '↗';
display: inline;
font-size: 0.65em;
margin-left: 2px;
opacity: 0.5;
vertical-align: 15%;
}
:global(.tiptap-wrapper .tiptap a[href$=".md"]::after) {
content: '⤴';
}
:global(.tiptap-wrapper .tiptap a:hover) { :global(.tiptap-wrapper .tiptap a:hover) {
text-decoration-color: var(--text-accent); text-decoration-color: var(--text-accent);
} }
:global(.tiptap-wrapper .tiptap a:hover::after) {
opacity: 0.8;
}
:global(.tiptap-wrapper .tiptap img) { :global(.tiptap-wrapper .tiptap img) {
display: block; display: block;
max-width: 100%; max-width: 100%;
@@ -5978,6 +6244,52 @@
opacity: 0.9; opacity: 0.9;
} }
.link-suggest-list {
max-height: 240px;
overflow-y: auto;
margin-top: 8px;
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 4px;
}
.link-suggest-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.link-suggest-item:hover,
.link-suggest-item.selected {
background: var(--accent-light);
color: var(--accent);
}
.link-suggest-item svg {
flex-shrink: 0;
color: var(--text-tertiary);
}
.link-suggest-item:hover svg,
.link-suggest-item.selected svg {
color: var(--accent);
}
.link-suggest-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Text context menu */ /* Text context menu */
.text-ctx-overlay { .text-ctx-overlay {
position: fixed; position: fixed;
@@ -6683,12 +6995,34 @@
color: var(--accent); color: var(--accent);
} }
.wiki-link-title-col {
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
}
.wiki-link-title { .wiki-link-title {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.wiki-link-folder {
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wiki-link-disambig-header {
padding: 8px 10px 4px;
font-size: 11px;
color: var(--text-tertiary);
font-weight: 500;
}
/* ═══ MOBILE (class-based, not media-query, for Android high-DPI) ═══ */ /* ═══ MOBILE (class-based, not media-query, for Android high-DPI) ═══ */
.editor-container.mobile { .editor-container.mobile {
height: 100%; height: 100%;
+190 -3
View File
@@ -44,6 +44,8 @@
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);
let multiSelectMode = $state(false);
/** 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() {
@@ -80,6 +82,52 @@
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false); let batchTagEdit = $state(false);
// Mobile long-press selection
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressTriggered = false;
let touchStartPos = { x: 0, y: 0 };
const LONG_PRESS_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
function handleTouchStart(e: TouchEvent, note: NoteEntry) {
longPressTriggered = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
longPressTimer = setTimeout(() => {
longPressTriggered = true;
// Vibrate for haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
const next = new Set(selectedPaths);
if (next.size === 0 && $activeNotePath && $activeNotePath !== note.path) {
next.add($activeNotePath);
}
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
}, LONG_PRESS_MS);
}
function handleTouchMove(e: TouchEvent) {
if (!longPressTimer) return;
const touch = e.touches[0];
const dx = touch.clientX - touchStartPos.x;
const dy = touch.clientY - touchStartPos.y;
if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy) > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Quick Access drag-to-reorder // Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
@@ -108,6 +156,7 @@
selectedPaths = new Set(); selectedPaths = new Set();
lastClickedPath = null; lastClickedPath = null;
batchMovePicker = false; batchMovePicker = false;
multiSelectMode = false;
} }
// Clear selection and reset scroll when view/notebook changes // Clear selection and reset scroll when view/notebook changes
@@ -504,6 +553,23 @@
} }
function handleNoteClick(e: MouseEvent, note: NoteEntry) { function handleNoteClick(e: MouseEvent, note: NoteEntry) {
// Mobile: if long-press just fired, ignore the click
if (isMobile && longPressTriggered) {
longPressTriggered = false;
e.preventDefault();
return;
}
// Mobile: if in selection mode (or Android multi-select mode), tap toggles selection
if (isMobile && (selectedPaths.size > 0 || multiSelectMode)) {
const next = new Set(selectedPaths);
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
return;
}
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Toggle individual selection // Toggle individual selection
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
@@ -599,7 +665,11 @@
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
sortMenu = { x: rect.left, y: rect.bottom + 4 }; const menuWidth = isMobile ? 220 : 180;
let x = rect.left;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (x < 4) x = 4;
sortMenu = { x, y: rect.bottom + 4 };
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
@@ -626,14 +696,21 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<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="3" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" />{#if multiSelectMode}<polyline points="6 6.5 7 7.5 9 5.5" stroke-width="2" /><polyline points="6 17.5 7 18.5 9 16.5" stroke-width="2" />{/if}<line x1="14" y1="6.5" x2="21" y2="6.5" /><line x1="14" y1="17.5" x2="21" y2="17.5" />
</svg>
</button>
{/if}
<button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}"> <button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> <line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
</svg> </svg>
</button> </button>
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class="icon-btn" onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
@@ -650,6 +727,7 @@
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
{:else} {:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button> <button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
<button class="selection-action danger" onclick={handleBatchDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchDelete}>Delete</button>
{/if} {/if}
<button class="selection-close" onclick={clearSelection} title="Clear selection"> <button class="selection-close" onclick={clearSelection} title="Clear selection">
@@ -688,6 +766,50 @@
</div> </div>
{/if} {/if}
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/>
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/each}
</div>
{:else}
<div class="context-empty">No common tags</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $sortedNotes.length === 0} {#if $sortedNotes.length === 0}
<div class="empty-state"> <div class="empty-state">
@@ -726,6 +848,10 @@
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY); const pos = clampMenu(e.clientX, e.clientY);
@@ -995,6 +1121,14 @@
</svg> </svg>
Move to... Move to...
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
Print / Export to PDF
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.note)}> <button class="danger" onclick={() => handleDelete(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/> <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
@@ -1066,6 +1200,7 @@
.list-actions { .list-actions {
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
} }
@@ -1085,6 +1220,11 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icon-btn.active-toggle {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.list-content { .list-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1566,6 +1706,10 @@
padding: 12px 16px; padding: 12px 16px;
} }
.note-list.mobile .list-actions {
gap: 10px;
}
.note-list.mobile .list-title { .note-list.mobile .list-title {
font-size: 16px; font-size: 16px;
} }
@@ -1630,6 +1774,9 @@
.note-list.mobile .selection-bar { .note-list.mobile .selection-bar {
padding: 8px 16px; padding: 8px 16px;
border-radius: 12px;
margin: 4px 8px;
border-bottom: none;
} }
.note-list.mobile .selection-count { .note-list.mobile .selection-count {
@@ -1640,6 +1787,7 @@
padding: 6px 14px; padding: 6px 14px;
font-size: 13px; font-size: 13px;
min-height: 36px; min-height: 36px;
border-radius: 10px;
} }
.note-list.mobile .context-menu { .note-list.mobile .context-menu {
@@ -1684,4 +1832,43 @@
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
} }
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.mobile-create-btn:active {
opacity: 0.8;
}
</style> </style>
+65 -23
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, 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 { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, 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 +33,35 @@
return resolved.join('/'); return resolved.join('/');
} }
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
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
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content });
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
}
}
// Detect install type and check for updates on startup // Detect install type and check for updates on startup
onMount(() => { onMount(() => {
if (isMobile) { if (isMobile) {
@@ -51,33 +80,46 @@
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
resolveAndHandleLink(href);
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
} }
document.addEventListener('click', handleLinkClick, true); document.addEventListener('click', handleLinkClick, true);
return () => document.removeEventListener('click', handleLinkClick, true);
// On Android WebView, tapping a link can trigger native navigation before
// the JS click event fires (causing 404s for relative .md links).
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
// preventDefault to block the native navigation and handle it ourselves.
let touchStartY = 0;
function handleTouchStart(e: TouchEvent) {
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
resolveAndHandleLink(href);
}
if (isMobile) {
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', handleTouchEnd, true);
}
return () => {
document.removeEventListener('click', handleLinkClick, true);
if (isMobile) {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchend', handleTouchEnd, true);
}
};
}); });
</script> </script>