Compare commits

...
8 Commits
15 changed files with 623 additions and 246 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.2.0) ## 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.2.0/HelixNotes_1.2.0_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.2.0/HelixNotes_1.2.0_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.2.0/HelixNotes_1.2.0_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.2.0/HelixNotes_1.2.0_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.2.0/HelixNotes_1.2.0_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
-174
View File
@@ -1,174 +0,0 @@
Categories:
- Writing
- Office
License: AGPL-3.0-or-later
AuthorName: ArkHost
AuthorWebSite: https://helixnotes.com
SourceCode: https://codeberg.org/ArkHost/HelixNotes
IssueTracker: https://codeberg.org/ArkHost/HelixNotes/issues
Changelog: https://codeberg.org/ArkHost/HelixNotes/releases
AutoName: HelixNotes
RepoType: git
Repo: https://codeberg.org/ArkHost/HelixNotes.git
Builds:
- versionName: 1.2.0
versionCode: 10200001
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add aarch64-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200002
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/arm/release/app-arm-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add armv7-linux-androideabi
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200003
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/x86/release/app-x86-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add i686-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200004
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/x86_64/release/app-x86_64-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add x86_64-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
AllowedAPKSigningKeys: TODO_YOUR_APK_SIGNING_CERT_SHA256
AutoUpdateMode: Version
UpdateCheckMode: Tags ^v[\d.]+$
CurrentVersion: 1.2.0
CurrentVersionCode: 10200004
+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.1", "version": "1.2.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+18 -1
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.1" 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",
@@ -3589,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"
@@ -4770,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"
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.1" 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"
+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]
+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> {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.1", "version": "1.2.3",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+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;
}
}
+199 -17
View File
@@ -742,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;
@@ -757,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();
@@ -768,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) {
@@ -777,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()
@@ -796,8 +845,13 @@
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;
if (wikiLinkDisambigEntries) {
// In disambiguation mode: use stored display/ref
insertWikiLink({ ...items[index], title: wikiLinkDisambigDisplay || items[index].title }, wikiLinkDisambigRef || undefined);
} else {
insertWikiLink(items[index]); insertWikiLink(items[index]);
} }
}
const WikiLink = TiptapMark.create({ const WikiLink = TiptapMark.create({
name: 'wikiLink', name: 'wikiLink',
@@ -849,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;
@@ -917,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;
@@ -991,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 };
@@ -1026,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' },
@@ -1917,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>`;
}); });
@@ -2339,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;
} }
@@ -3266,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 =>
@@ -4467,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-col">
<span class="wiki-link-title">{entry.title}</span> <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}
@@ -4478,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}>
@@ -5248,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;
@@ -5276,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;
@@ -6835,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%;
+78 -13
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() {
@@ -154,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
@@ -556,8 +559,8 @@
e.preventDefault(); e.preventDefault();
return; return;
} }
// Mobile: if in selection mode, tap toggles selection // Mobile: if in selection mode (or Android multi-select mode), tap toggles selection
if (isMobile && selectedPaths.size > 0) { if (isMobile && (selectedPaths.size > 0 || multiSelectMode)) {
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
if (next.has(note.path)) { if (next.has(note.path)) {
next.delete(note.path); next.delete(note.path);
@@ -693,6 +696,13 @@
<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" />
@@ -717,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">
@@ -755,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">
@@ -793,10 +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) handleTouchStart(e, note); }} ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile) handleTouchMove(e); }} ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile) handleTouchEnd(); }} ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile) 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);
@@ -846,13 +901,6 @@
}} }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
> >
{#if isMobile && selectedPaths.size > 0}
<div class="mobile-select-check" class:checked={selectedPaths.has(note.path)}>
{#if selectedPaths.has(note.path)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
{/if}
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
<span class="note-title"> <span class="note-title">
@@ -1073,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"/>
@@ -1164,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;
@@ -1713,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 {
@@ -1723,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 {