Compare commits

...
19 Commits
Author SHA1 Message Date
Yuri Karamian 1bc067d470 v1.2.6 - unfiled notes, keyboard shortcuts, Android fixes 2026-03-19 00:36:42 +01:00
Yuri Karamian 565688b284 Restore trashed notebooks to their original location instead of vault root 2026-03-17 22:48:24 +01:00
Yuri Karamian e87a2edc00 Add keyboard shortcuts for sidebar, theme, focus mode, read-only, and fullscreen toggles 2026-03-17 22:37:48 +01:00
Yuri Karamian 9f595212dd add unfiled notes 2026-03-17 21:45:51 +01:00
Yuri Karamian 79f16b454a update README.md 2026-03-12 01:06:53 +01:00
Yuri Karamian 7be1b56b7c v1.2.5 - calendar view for daily notes, trash notebook restore, attachment/link fixes 2026-03-12 00:12:45 +01:00
Yuri Karamian 0b17a7c458 update README.md 2026-03-09 18:03:50 +01:00
Yuri Karamian 6c2bc72fbb v1.2.4 - proxy external images through imgproxy protocol for WebKitGTK compat, hide copy/open buttons for external images 2026-03-09 15:58:58 +01:00
Yuri Karamian e67ec21319 update README.md 2026-03-08 19:13:01 +01:00
Yuri Karamian e87c8cbe1d update README.md 2026-03-06 21:49:44 +01:00
Yuri Karamian 4fac6d05a7 update README.md 2026-03-06 21:46:45 +01:00
Yuri Karamian fffea45c50 v1.2.3 2026-03-06 20:20:15 +01:00
Yuri Karamian eb88955e20 v1.2.2 2026-03-03 13:58:12 +01:00
Yuri Karamian 5180bd5a38 v1.2.2 - Localized daily note titles, Android light mode status bar fix, Print/Export to PDF 2026-03-03 13:28:04 +01:00
Yuri Karamian a6b9037ae0 Add fastlane metadata for F-Droid / IzzyOnDroid 2026-03-01 00:56:06 +01:00
Yuri Karamian 88251a503c remove fdroid template data 2026-03-01 00:29:34 +01:00
Yuri Karamian 283340a0f4 update README.md 2026-03-01 00:11:17 +01:00
Yuri Karamian 545d4aec3b update README.md 2026-03-01 00:07:54 +01:00
Yuri Karamian 9315151a64 update README.md 2026-02-28 23:53:21 +01:00
26 changed files with 1565 additions and 377 deletions
+21 -26
View File
@@ -1,11 +1,16 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.5-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.2.0) ## Download (v1.2.5)
### Linux ### Linux
@@ -23,23 +28,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.0/HelixNotes_1.2.0_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.5/HelixNotes_1.2.5_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.5/HelixNotes_1.2.5_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.5/HelixNotes_1.2.5_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.5/HelixNotes_1.2.5_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://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.5/HelixNotes_1.2.5_android.apk)
--- ---
@@ -47,26 +52,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 with calendar view, 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.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+19 -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.5"
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"
@@ -3930,6 +3938,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"http", "http",
@@ -4770,6 +4779,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"
+4 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.1" version = "1.2.5"
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"
@@ -34,7 +35,7 @@ tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
regex = "1" regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
+2
View File
@@ -12,6 +12,8 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title", "core:window:allow-set-title",
"core:webview:allow-create-webview-window", "core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
+39 -12
View File
@@ -112,6 +112,13 @@ pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, S
operations::scan_notebooks(vault_path) operations::scan_notebooks(vault_path)
} }
#[tauri::command]
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::count_root_notes(vault_path)
}
#[tauri::command] #[tauri::command]
pub fn create_notebook( pub fn create_notebook(
state: State<'_, AppState>, state: State<'_, AppState>,
@@ -201,9 +208,11 @@ pub fn move_notebook(
#[tauri::command] #[tauri::command]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; config.active_vault.as_ref().ok_or("No active vault")?.clone()
operations::delete_notebook(vault_path, &path) };
operations::delete_notebook(&vault_path, &path)
} }
// ── Notes ── // ── Notes ──
@@ -272,10 +281,10 @@ pub fn create_note(
} }
#[tauri::command] #[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String> { pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path)?; let entry = operations::create_daily_note(vault_path, date.as_deref())?;
if let Ok(search_guard) = state.search_index.lock() { if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard { if let Some(ref search) = *search_guard {
@@ -287,8 +296,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]
@@ -551,10 +563,10 @@ pub fn reindex(state: State<'_, AppState>) -> Result<(), String> {
// ── Trash ── // ── Trash ──
#[tauri::command] #[tauri::command]
pub fn get_trash(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> { pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::get_trash_notes(vault_path) operations::get_trash_contents(vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -563,14 +575,29 @@ pub fn restore_note(
trash_path: String, trash_path: String,
dest_notebook: Option<String>, dest_notebook: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; config.active_vault.as_ref().ok_or("No active vault")?.clone()
operations::restore_note(vault_path, &trash_path, dest_notebook.as_deref()) };
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
} }
#[tauri::command] #[tauri::command]
pub fn permanent_delete(path: String) -> Result<(), String> { pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
operations::permanent_delete(&path) let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_notebook(&vault_path, &trash_path)
}
#[tauri::command]
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::permanent_delete(&vault_path, &path)
} }
#[tauri::command] #[tauri::command]
+96 -1
View File
@@ -98,6 +98,7 @@ pub fn run() {
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::get_notebooks, commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook, commands::create_notebook,
commands::rename_notebook, commands::rename_notebook,
commands::delete_notebook, commands::delete_notebook,
@@ -117,6 +118,7 @@ pub fn run() {
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
commands::restore_note, commands::restore_note,
commands::restore_notebook,
commands::permanent_delete, commands::permanent_delete,
commands::empty_trash, commands::empty_trash,
commands::load_vault_state, commands::load_vault_state,
@@ -149,7 +151,80 @@ pub fn run() {
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file, commands::get_pending_open_file,
]); ])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
let path = request.uri().path().to_string();
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
{ {
@@ -251,3 +326,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+14
View File
@@ -19,6 +19,20 @@ pub struct NoteEntry {
pub preview: String, pub preview: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashContents {
pub notes: Vec<NoteEntry>,
pub notebooks: Vec<TrashNotebookEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashNotebookEntry {
pub name: String,
pub path: String,
pub note_count: usize,
pub modified: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotebookEntry { pub struct NotebookEntry {
pub name: String, pub name: String,
+323 -16
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Utc}; use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -63,7 +63,9 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut dirs: Vec<_> = read_dir let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| { .filter(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path()) e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
}) })
.collect(); .collect();
@@ -165,6 +167,22 @@ fn is_hidden(path: &Path) -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> { pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path); let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path); let root = Path::new(scan_path);
@@ -276,7 +294,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 +312,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 +469,66 @@ pub fn create_note(
}) })
} }
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { fn get_system_locale() -> Locale {
let today = Local::now(); let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let date_str = today.format("%Y-%m-%d").to_string(); let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
let title = today.format("%B %d, %Y").to_string(); 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, date: Option<&str>) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily"); let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -567,17 +643,26 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
let trash_name = format!("{}_{}", timestamp, dirname); let trash_name = format!("{}_{}", timestamp, dirname);
let dest = trash_dir.join(&trash_name); let dest = trash_dir.join(&trash_name);
// Save original relative path in a sidecar .meta file for restore
let relative = src
.strip_prefix(vault_path)
.unwrap_or(src)
.to_string_lossy()
.to_string();
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
let _ = fs::write(&meta_path, &relative);
fs::rename(src, dest).map_err(|e| e.to_string())?; fs::rename(src, dest).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
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 +670,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 +690,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> {
@@ -669,14 +885,29 @@ pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, S
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> { /// Remove a directory inside trash if it's empty (after restoring/deleting its last note).
fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if let Some(d) = dir {
if d != trash_dir && d.starts_with(&trash_dir) {
if let Ok(mut entries) = fs::read_dir(d) {
if entries.next().is_none() {
let _ = fs::remove_dir(d);
}
}
}
}
}
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { if !trash_dir.exists() {
return Ok(Vec::new()); return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
} }
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
let mut notes = Vec::new(); let mut notes = Vec::new();
let mut notebooks = Vec::new();
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? { for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?; let entry = entry.map_err(|e| e.to_string())?;
@@ -685,11 +916,39 @@ pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
if let Ok(note) = read_note_entry(&path, vault_root) { if let Ok(note) = read_note_entry(&path, vault_root) {
notes.push(note); notes.push(note);
} }
} else if path.is_dir() {
// Deleted notebook — count .md files inside
let note_count = WalkDir::new(&path)
.min_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
.count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
dirname[18..].to_string()
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
dirname[15..].to_string()
} else {
dirname.to_string()
};
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
name,
path: path.to_string_lossy().to_string(),
note_count,
modified,
});
} }
} }
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes) notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(TrashContents { notes, notebooks })
} }
pub fn restore_note( pub fn restore_note(
@@ -717,18 +976,66 @@ pub fn restore_note(
&filename &filename
}; };
let parent = src.parent().map(|p| p.to_path_buf());
let dest = dest_dir.join(original_name); let dest = dest_dir.join(original_name);
fs::rename(src, &dest).map_err(|e| e.to_string())?; fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn permanent_delete(path: &str) -> Result<(), String> { pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() || !src.is_dir() {
return Err("Trashed notebook does not exist".to_string());
}
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
// Fallback for notebooks trashed before .meta files existed:
// strip timestamp prefix to get notebook name, restore to root
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
} else {
&dirname
};
name.to_string()
};
let dest = Path::new(vault_path).join(&relative);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up .meta file
let _ = fs::remove_file(&meta_path);
Ok(dest.to_string_lossy().to_string())
}
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path); let p = Path::new(path);
let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() { if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?; fs::remove_dir_all(p).map_err(|e| e.to_string())?;
} else { } else {
fs::remove_file(p).map_err(|e| e.to_string())?; fs::remove_file(p).map_err(|e| e.to_string())?;
} }
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(()) Ok(())
} }
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.1", "version": "1.2.6",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost", "csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
+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;
}
}
+12 -3
View File
@@ -7,6 +7,7 @@ import type {
NotebookEntry, NotebookEntry,
NoteTitleEntry, NoteTitleEntry,
SearchResult, SearchResult,
TrashContents,
VaultState, VaultState,
VaultStats, VaultStats,
ImportResult, ImportResult,
@@ -46,6 +47,10 @@ export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
export async function countRootNotes(): Promise<number> {
return invoke("count_root_notes");
}
export async function createNotebook( export async function createNotebook(
parentRelative: string | null, parentRelative: string | null,
name: string, name: string,
@@ -96,8 +101,8 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title }); return invoke("create_note", { notebookRelative, title });
} }
export async function createDailyNote(): Promise<NoteEntry> { export async function createDailyNote(date?: string): Promise<NoteEntry> {
return invoke("create_daily_note"); return invoke("create_daily_note", { date: date ?? null });
} }
export async function renameNote( export async function renameNote(
@@ -141,7 +146,7 @@ export async function reindex(): Promise<void> {
return invoke("reindex"); return invoke("reindex");
} }
export async function getTrash(): Promise<NoteEntry[]> { export async function getTrash(): Promise<TrashContents> {
return invoke("get_trash"); return invoke("get_trash");
} }
@@ -152,6 +157,10 @@ export async function restoreNote(
return invoke("restore_note", { trashPath, destNotebook }); return invoke("restore_note", { trashPath, destNotebook });
} }
export async function restoreNotebook(trashPath: string): Promise<string> {
return invoke("restore_notebook", { trashPath });
}
export async function permanentDelete(path: string): Promise<void> { export async function permanentDelete(path: string): Promise<void> {
return invoke("permanent_delete", { path }); return invoke("permanent_delete", { path });
} }
+34 -5
View File
@@ -33,6 +33,7 @@
quickAccessPaths, quickAccessPaths,
tags, tags,
notes, notes,
viewMode,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab settingsTab
} from '$lib/stores/app'; } from '$lib/stores/app';
@@ -40,7 +41,7 @@
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -183,7 +184,8 @@
$activeNotePath = entry.path; $activeNotePath = entry.path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(entry.path, content.content); editor?.loadNote(entry.path, content.content);
noteList?.refresh(); $viewMode = 'daily';
noteList?.refresh(true);
sidebar?.refresh(); sidebar?.refresh();
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
@@ -266,6 +268,11 @@
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && e.key === 'N') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && e.key === 'F') {
e.preventDefault(); e.preventDefault();
@@ -303,6 +310,26 @@
openNoteWindow($activeNotePath, $activeNote.meta.title); openNoteWindow($activeNotePath, $activeNote.meta.title);
} }
} }
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && e.key === 'E') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && e.key === 'R') {
e.preventDefault();
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -402,9 +429,11 @@
debouncedRefresh(); debouncedRefresh();
}); });
} else { } else {
unlistenFileChange = await listen<FileEvent>('file-changed', async () => { const debouncedDesktopRefresh = debounce(async () => {
await sidebar?.refresh(); await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
await noteList?.refresh(true); }, 300);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedDesktopRefresh();
}); });
} }
+295 -67
View File
@@ -199,6 +199,7 @@
let linkModalInput = $state<HTMLInputElement>(null!); let linkModalInput = $state<HTMLInputElement>(null!);
let linkSelectionFrom = 0; let linkSelectionFrom = 0;
let linkSelectionTo = 0; let linkSelectionTo = 0;
let linkModalDisplayText = '';
let linkSuggestIndex = $state(0); let linkSuggestIndex = $state(0);
let linkSuggestTitles = $state<NoteTitleEntry[]>([]); let linkSuggestTitles = $state<NoteTitleEntry[]>([]);
let linkSuggestFiltered = $derived.by(() => { let linkSuggestFiltered = $derived.by(() => {
@@ -306,17 +307,8 @@
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const src = HTMLAttributes.src || ''; const src = HTMLAttributes.src || '';
const name = HTMLAttributes.name || 'file.pdf'; const name = HTMLAttributes.name || 'file.pdf';
if (isMobile) { const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
// On mobile, render as a clickable link instead of iframe if (showInline) {
const vaultRoot = $appConfig?.active_vault ?? '';
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }),
['a', { href: '#', 'data-open-file': absPath, class: 'pdf-link-mobile', onclick: 'return false;' },
['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'],
['span', {}, name],
],
];
}
const vaultRoot = $appConfig?.active_vault ?? ''; const vaultRoot = $appConfig?.active_vault ?? '';
const pdfHeight = $appConfig?.pdf_height ?? 600; const pdfHeight = $appConfig?.pdf_height ?? 600;
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`); const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(src)}`);
@@ -325,6 +317,14 @@
['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }], ['iframe', { src: displaySrc, width: '100%', height: `${pdfHeight}px`, frameborder: '0' }],
['p', { class: 'pdf-label' }, name], ['p', { class: 'pdf-label' }, name],
]; ];
}
// Non-inline: render as a clickable link (mobile + desktop with setting off)
return ['div', mergeAttributes({ 'data-pdf-src': src, 'data-pdf-name': name, class: 'pdf-embed-mobile' }),
['a', { href: decodeURIComponent(src), class: 'pdf-link-mobile' },
['span', { class: 'pdf-icon-mobile' }, '\uD83D\uDCC4'],
['span', {}, name],
],
];
}, },
}); });
@@ -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' },
@@ -1071,10 +1180,19 @@
]; ];
function resolveImageSrc(src: string): string { function resolveImageSrc(src: string): string {
// Already an absolute URL or data URI // Already a proxied, asset, data, or blob URL
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:')) { if (src.startsWith('data:') || src.startsWith('asset:') || src.startsWith('blob:') || src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
return src; return src;
} }
// Already an asset-localhost URL
if (src.startsWith('http://asset.localhost') || src.startsWith('https://asset.localhost')) {
return src;
}
// External http/https URLs: proxy through Tauri's imgproxy protocol
// to bypass WebKitGTK restrictions on loading external resources
if (src.startsWith('http://') || src.startsWith('https://')) {
return convertFileSrc(src, 'imgproxy');
}
// Decode percent-encoding (%20 → space, etc.) for filesystem resolution // Decode percent-encoding (%20 → space, etc.) for filesystem resolution
let decoded = decodeURIComponent(src); let decoded = decodeURIComponent(src);
// Fix multiple leading slashes (from broken saves) // Fix multiple leading slashes (from broken saves)
@@ -1767,6 +1885,15 @@
function stripAssetSrc(src: string): string { function stripAssetSrc(src: string): string {
// blob: URLs are not persistable — they were temporary browser references // blob: URLs are not persistable — they were temporary browser references
if (src.startsWith('blob:')) return ''; if (src.startsWith('blob:')) return '';
// Convert imgproxy:// URLs back to original external URLs for saving
if (src.startsWith('imgproxy:') || src.startsWith('http://imgproxy.localhost') || src.startsWith('https://imgproxy.localhost')) {
try {
const url = new URL(src);
return decodeURIComponent(url.pathname.substring(1));
} catch {
return src;
}
}
// Convert asset:// URLs back to relative paths for saving // Convert asset:// URLs back to relative paths for saving
if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src; if (!src.startsWith('asset:') && !src.startsWith('http://asset.localhost')) return src;
let absPath = ''; let absPath = '';
@@ -1917,8 +2044,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>`;
}); });
@@ -1938,16 +2085,17 @@
return `[${text}](${url.replace(/ /g, '%20')})`; return `[${text}](${url.replace(/ /g, '%20')})`;
}); });
// Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile // Pre-process: transform PDF embed divs — iframes when inline preview is on, clickable links otherwise
src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => { src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => {
const vaultRoot = $appConfig?.active_vault ?? ''; const vaultRoot = $appConfig?.active_vault ?? '';
const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`); const absPath = normalizePath(`${vaultRoot}/${decodeURIComponent(pdfSrc)}`);
if (isMobile) { const showInline = !isMobile && ($appConfig?.pdf_preview ?? false);
return `<div class="pdf-embed-mobile"><a href="#" data-open-file="${absPath}" class="pdf-link-mobile" onclick="return false;">\uD83D\uDCC4 ${name}</a></div>`; if (showInline) {
}
const pdfHeight = $appConfig?.pdf_height ?? 600; const pdfHeight = $appConfig?.pdf_height ?? 600;
const displaySrc = convertFileSrc(absPath); const displaySrc = convertFileSrc(absPath);
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`; return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed"><iframe src="${displaySrc}" width="100%" height="${pdfHeight}px"></iframe><p class="pdf-label">${name}</p></div>`;
}
return `<div data-pdf-src="${pdfSrc}" data-pdf-name="${name}" class="pdf-embed-mobile"><a href="${decodeURIComponent(pdfSrc)}" class="pdf-link-mobile">\uD83D\uDCC4 ${name}</a></div>`;
}); });
// Pre-process: render KaTeX math — only outside fenced code blocks // Pre-process: render KaTeX math — only outside fenced code blocks
@@ -2168,20 +2316,6 @@
} }
} }
}, },
// On mobile, handle clicks on PDF file links to open in external app
...(isMobile ? { click: (_view: any, event: MouseEvent) => {
const target = (event.target as HTMLElement).closest('[data-open-file]') as HTMLElement | null;
if (target) {
event.preventDefault();
const filePath = target.getAttribute('data-open-file');
if (filePath) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(filePath);
}
}
}
}} : {}),
// Prevent native text drag — it causes copy-instead-of-move in Tauri's webview. // Prevent native text drag — it causes copy-instead-of-move in Tauri's webview.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead. // File drops from OS are handled by Tauri's onDragDropEvent listener instead.
dragstart: (_view, event) => { dragstart: (_view, event) => {
@@ -2269,10 +2403,21 @@
} }
// Store raw URL — encoding is handled during markdown serialization/parsing // Store raw URL — encoding is handled during markdown serialization/parsing
const href = url.replace(/[()]/g, (c) => encodeURIComponent(c)); const href = url.replace(/[()]/g, (c) => encodeURIComponent(c));
if (linkSelectionFrom === linkSelectionTo) {
// No text selected — insert link text with the mark
const text = linkModalDisplayText || url.replace(/\.md$/, '').split('/').pop() || url;
editor.chain().focus().setTextSelection(linkSelectionFrom).insertContent({
type: 'text',
text,
marks: [{ type: 'link', attrs: { href } }],
}).run();
} else {
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run(); editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run();
} }
}
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
linkModalDisplayText = '';
} }
function linkModalSelectNote(entry: NoteTitleEntry) { function linkModalSelectNote(entry: NoteTitleEntry) {
@@ -2292,12 +2437,14 @@
} else { } else {
linkModalUrl = entry.title + '.md'; linkModalUrl = entry.title + '.md';
} }
linkModalDisplayText = entry.title;
linkModalConfirm(); linkModalConfirm();
} }
function linkModalCancel() { function linkModalCancel() {
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
linkModalDisplayText = '';
editor?.chain().focus().run(); editor?.chain().focus().run();
} }
@@ -2339,7 +2486,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;
} }
@@ -2867,19 +3014,7 @@
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch(console.error); openUrl(href).catch(console.error);
} else { } else {
const decoded = decodeURIComponent(href); openFile(resolveHrefToAbsPath(href)).catch(console.error);
let absPath = decoded;
if (!decoded.startsWith('/')) {
const np = $activeNotePath;
if (np) {
const noteDir = np.substring(0, np.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch(console.error);
} }
} }
@@ -2899,8 +3034,13 @@
if (pos >= 0) { if (pos >= 0) {
editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run(); editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run();
} }
const { from, to } = editor.state.selection;
linkSelectionFrom = from;
linkSelectionTo = to;
linkModalUrl = decodeURIComponent(href); linkModalUrl = decodeURIComponent(href);
linkModalDisplayText = '';
linkModal = true; linkModal = true;
getAllNoteTitles().then(t => { linkSuggestTitles = t; }).catch(() => {});
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -2924,12 +3064,16 @@
function resolveHrefToAbsPath(href: string): string { function resolveHrefToAbsPath(href: string): string {
const decoded = decodeURIComponent(href); const decoded = decodeURIComponent(href);
if (decoded.startsWith('/')) return decoded; if (decoded.startsWith('/')) return decoded;
// .helixnotes/ paths are always relative to vault root, not the note's directory
const vaultRoot = $appConfig?.active_vault;
if (decoded.startsWith('.helixnotes/') && vaultRoot) {
return normalizePath(`${vaultRoot}/${decoded}`);
}
const notePath = $activeNotePath; const notePath = $activeNotePath;
if (notePath) { if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`); return normalizePath(`${noteDir}/${decoded}`);
} }
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`); if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return decoded; return decoded;
} }
@@ -3266,6 +3410,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 =>
@@ -3632,7 +3778,7 @@
{/each} {/each}
</div> </div>
{/if} {/if}
{#if historySelected && historyPreview !== null} {#if historySelected}
<div class="history-actions"> <div class="history-actions">
<button class="history-restore-btn" onclick={restoreVersion}> <button class="history-restore-btn" onclick={restoreVersion}>
Restore this version Restore this version
@@ -4358,7 +4504,7 @@
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button> <button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button> <button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button> <button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
{#if !isMobile} {#if !isMobile && !imageToolbar.src.startsWith('imgproxy:') && !imageToolbar.src.startsWith('http://imgproxy.localhost')}
<span class="img-toolbar-sep"></span> <span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image"> <button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
@@ -4467,10 +4613,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 +4629,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 +5423,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 +5451,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 +7010,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%;
@@ -6983,14 +7180,45 @@
position: relative; position: relative;
} }
.editor-container.mobile .editor-body-row:has(.history-panel) > .editor-body,
.editor-container.mobile .editor-body-row:has(.outline-panel) > .editor-body {
display: none;
}
.editor-container.mobile .history-panel, .editor-container.mobile .history-panel,
.editor-container.mobile .outline-panel { .editor-container.mobile .outline-panel {
position: absolute; position: static;
inset: 0; flex: 1;
min-height: 0;
width: 100% !important; width: 100% !important;
max-width: 100%; max-width: 100%;
border-left: none; border-left: none;
z-index: 10; }
.editor-container.mobile .history-item {
padding: 12px 12px;
min-height: 48px;
}
.editor-container.mobile .history-list {
padding-bottom: 80px;
}
.editor-container.mobile .history-restore-btn {
padding: 14px 16px;
font-size: 15px;
}
.editor-container.mobile .history-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--bg-secondary);
border-top: 1px solid var(--border-light);
z-index: 51;
} }
.editor-container.mobile .note-search-bar { .editor-container.mobile .note-search-bar {
+22 -7
View File
@@ -116,22 +116,37 @@
</div> </div>
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4> <h4 class="shortcuts-group-title">General</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<h4 class="shortcuts-group-title">Interface</h4>
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Navigation</h4>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<h4 class="shortcuts-group-title">Formatting</h4>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Strikethrough</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Code</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>E</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Link</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>K</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
<h4 class="shortcuts-group-title">Editor Commands</h4> <h4 class="shortcuts-group-title">Editor Commands</h4>
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
+424 -17
View File
@@ -24,17 +24,19 @@
saveNote, saveNote,
getTrash, getTrash,
restoreNote, restoreNote,
restoreNotebook,
permanentDelete, permanentDelete,
getQuickAccess, getQuickAccess,
addQuickAccess, addQuickAccess,
removeQuickAccess, removeQuickAccess,
reorderQuickAccess, reorderQuickAccess,
moveNote, moveNote,
getAllTags getAllTags,
createDailyNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
@@ -44,6 +46,69 @@
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);
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
let trashBusy = $state<string | null>(null);
// Calendar state for daily notes view
let calMonth = $state(new Date().getMonth());
let calYear = $state(new Date().getFullYear());
let dailyDates = $state<Set<string>>(new Set());
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const calDayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
function calDays(): Array<{ day: number; date: string; current: boolean }> {
const first = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
// Monday=0 offset
let startDow = first.getDay() - 1;
if (startDow < 0) startDow = 6;
const cells: Array<{ day: number; date: string; current: boolean }> = [];
for (let i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDay; d++) {
const mm = String(calMonth + 1).padStart(2, '0');
const dd = String(d).padStart(2, '0');
const date = `${calYear}-${mm}-${dd}`;
const now = new Date();
const current = d === now.getDate() && calMonth === now.getMonth() && calYear === now.getFullYear();
cells.push({ day: d, date, current });
}
return cells;
}
function calPrev() {
if (calMonth === 0) { calMonth = 11; calYear--; }
else calMonth--;
}
function calNext() {
if (calMonth === 11) { calMonth = 0; calYear++; }
else calMonth++;
}
function calToday() {
const now = new Date();
calMonth = now.getMonth();
calYear = now.getFullYear();
}
async function handleDayClick(date: string) {
try {
const entry = await createDailyNote(date);
const content = await readNote(entry.path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = entry.path;
onNoteSelected(entry.path, content.content);
if (!dailyDates.has(date)) {
dailyDates = new Set([...dailyDates, date]);
}
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to open daily note:', e);
}
}
/** Derive tags from current $notes store (avoids re-scanning files on mobile) */ /** Derive tags from current $notes store (avoids re-scanning files on mobile) */
function deriveTagsFromNotes() { function deriveTagsFromNotes() {
@@ -154,6 +219,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
@@ -202,6 +268,7 @@
if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook'; if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook';
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -211,6 +278,7 @@
function cacheKey(): string { function cacheKey(): string {
if ($viewMode === 'trash') return 'trash'; if ($viewMode === 'trash') return 'trash';
if ($viewMode === 'daily') return 'daily';
if ($viewMode === 'quickaccess') return 'quickaccess'; if ($viewMode === 'quickaccess') return 'quickaccess';
if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`; if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`;
if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`; if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`;
@@ -231,7 +299,24 @@
try { try {
if ($viewMode === 'trash') { if ($viewMode === 'trash') {
$notes = await getTrash(); const trash = await getTrash();
$notes = trash.notes;
trashNotebooks = trash.notebooks;
} else if ($viewMode === 'daily') {
const vault = $appConfig?.active_vault;
if (vault) {
$notes = await getNotes(`${vault}/Daily`);
// Build set of dates from filenames (YYYY-MM-DD.md)
const dates = new Set<string>();
for (const n of $notes) {
const fname = n.path.split('/').pop() ?? '';
const m = fname.match(/^(\d{4}-\d{2}-\d{2})\.md$/);
if (m) dates.add(m[1]);
}
dailyDates = dates;
} else {
$notes = [];
}
} else if ($viewMode === 'quickaccess') { } else if ($viewMode === 'quickaccess') {
$notes = await getQuickAccess(); $notes = await getQuickAccess();
} else if ($viewMode === 'tag' && $activeTag) { } else if ($viewMode === 'tag' && $activeTag) {
@@ -270,6 +355,14 @@
} }
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'daily') {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
await handleDayClick(date);
calMonth = today.getMonth();
calYear = today.getFullYear();
return;
}
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null; const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
@@ -348,6 +441,32 @@
} }
} }
async function handleRestoreNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await restoreNotebook(nb.path);
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to restore notebook:', e);
} finally {
trashBusy = null;
}
}
async function handleDeleteNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await permanentDelete(nb.path);
noteCache.clear();
trashNotebooks = trashNotebooks.filter(n => n.path !== nb.path);
} catch (e) {
console.error('Failed to delete notebook permanently:', e);
} finally {
trashBusy = null;
}
}
async function handleTogglePin(note: NoteEntry) { async function handleTogglePin(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -556,8 +675,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 +812,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 +843,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,8 +882,86 @@
</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 $viewMode === 'daily'}
<div class="cal">
<div class="cal-nav">
<button class="cal-nav-btn" onclick={calPrev} title="Previous month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="cal-title" onclick={calToday}>{calMonthNames[calMonth]} {calYear}</button>
<button class="cal-nav-btn" onclick={calNext} title="Next month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="cal-grid">
{#each calDayNames as name}
<div class="cal-head">{name}</div>
{/each}
{#each calDays() as cell}
{#if cell.day === 0}
<div class="cal-cell empty"></div>
{:else}
<button
class="cal-cell"
class:today={cell.current}
class:has-note={dailyDates.has(cell.date)}
class:active={$activeNotePath?.endsWith(`${cell.date}.md`) ?? false}
onclick={() => handleDayClick(cell.date)}
>
{cell.day}
</button>
{/if}
{/each}
</div>
</div>
{/if}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
@@ -767,6 +972,37 @@
</div> </div>
{/if} {/if}
{#if $viewMode === 'trash' && trashNotebooks.length > 0}
{#each trashNotebooks as nb (nb.path)}
<div class="note-item trash-notebook" class:busy={trashBusy === nb.path}>
<div class="note-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
{nb.name}
</div>
{#if trashBusy === nb.path}
<div class="note-preview trash-busy">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
Working...
</div>
{:else}
<div class="note-preview">{nb.note_count} {nb.note_count === 1 ? 'note' : 'notes'}</div>
<div class="trash-notebook-actions">
<button class="trash-nb-btn restore" onclick={() => handleRestoreNotebook(nb)} title="Restore notebook">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-11.36L1 10"/></svg>
Restore
</button>
<button class="trash-nb-btn delete" onclick={() => handleDeleteNotebook(nb)} title="Delete permanently">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete
</button>
</div>
{/if}
</div>
{/each}
{/if}
<div style="height: {topPad}px"></div> <div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)} {#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i} {@const noteIndex = startIndex + i}
@@ -793,10 +1029,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 +1082,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 +1302,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 +1401,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 +1955,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 +1968,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 {
@@ -1806,4 +2052,165 @@
.mobile-create-btn:active { .mobile-create-btn:active {
opacity: 0.8; opacity: 0.8;
} }
.trash-notebook .note-title {
display: flex;
align-items: center;
gap: 6px;
}
.trash-notebook-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
.trash-nb-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: none;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
background: var(--bg-hover);
color: var(--text-secondary);
}
.trash-nb-btn:hover {
background: var(--bg-active);
}
.trash-nb-btn.delete:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.trash-notebook.busy {
opacity: 0.6;
pointer-events: none;
}
.trash-busy {
display: flex;
align-items: center;
gap: 6px;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Calendar */
.cal {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cal-nav-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
border-radius: 4px;
display: flex;
align-items: center;
}
.cal-nav-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.cal-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
background: none;
border: none;
cursor: pointer;
padding: 2px 8px;
border-radius: 4px;
}
.cal-title:hover {
background: var(--bg-hover);
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px 2px;
padding: 0 2px;
}
.cal-head {
font-size: 0.65rem;
color: var(--text-secondary);
text-align: center;
padding: 2px 0;
font-weight: 500;
}
.cal-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
margin: 0 auto;
}
.cal-cell.empty {
cursor: default;
}
.cal-cell:not(.empty):hover {
background: var(--bg-hover);
}
.cal-cell.today {
color: var(--accent);
font-weight: 700;
}
.cal-cell.has-note {
background: color-mix(in srgb, var(--accent) 15%, transparent);
color: var(--text-primary);
font-weight: 600;
}
.cal-cell.has-note:hover {
background: color-mix(in srgb, var(--accent) 25%, transparent);
}
.cal-cell.active {
background: var(--accent);
color: white;
font-weight: 600;
}
.cal-cell.active:hover {
background: var(--accent);
}
</style> </style>
+57 -4
View File
@@ -15,9 +15,10 @@
notebookIcons, notebookIcons,
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks collapsedNotebooks,
rootNoteCount
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
@@ -53,16 +54,20 @@
try { try {
if (isMobile) { if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead) // On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes] = await Promise.all([ const [nbs, icons, qaNotes, rootCount] = await Promise.all([
getNotebooks(), getNotebooks(),
getNotebookIcons(), getNotebookIcons(),
getQuickAccess(), getQuickAccess(),
countRootNotes(),
]); ]);
$notebooks = nbs; $notebooks = nbs;
$notebookIcons = icons; $notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else { } else {
$notebooks = await getNotebooks(); const [nbs, rootCount] = await Promise.all([getNotebooks(), countRootNotes()]);
$notebooks = nbs;
$rootNoteCount = rootCount;
$tags = await getAllTags(); $tags = await getAllTags();
$notebookIcons = await getNotebookIcons(); $notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess(); const qaNotes = await getQuickAccess();
@@ -80,6 +85,19 @@
onViewChanged(); onViewChanged();
} }
function selectUnfiled() {
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
selectAllNotes();
return;
}
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) { function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook // Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
@@ -106,6 +124,13 @@
onViewChanged(); onViewChanged();
} }
function selectDaily() {
$viewMode = 'daily';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -379,6 +404,20 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<button
class="nav-item"
class:active={$viewMode === 'daily'}
onclick={selectDaily}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily Notes</span>
</button>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'trash'} class:active={$viewMode === 'trash'}
@@ -445,6 +484,20 @@
{/if} {/if}
<div class="notebook-list"> <div class="notebook-list">
{#if $rootNoteCount > 0}
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
>
<span class="chevron-spacer"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each $notebooks as nb (nb.path)} {#each $notebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
+1
View File
@@ -31,6 +31,7 @@ export const collapsedNotebooks = writable<string[]>([]);
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0);
export const notes = writable<NoteEntry[]>([]); export const notes = writable<NoteEntry[]>([]);
export const tags = writable<[string, number][]>([]); export const tags = writable<[string, number][]>([]);
export const activeNote = writable<NoteContent | null>(null); export const activeNote = writable<NoteContent | null>(null);
+14 -1
View File
@@ -14,6 +14,18 @@ export interface NoteEntry {
preview: string; preview: string;
} }
export interface TrashNotebookEntry {
name: string;
path: string;
note_count: number;
modified: string;
}
export interface TrashContents {
notes: NoteEntry[];
notebooks: TrashNotebookEntry[];
}
export interface NotebookEntry { export interface NotebookEntry {
name: string; name: string;
path: string; path: string;
@@ -131,4 +143,5 @@ export type ViewMode =
| "tag" | "tag"
| "trash" | "trash"
| "search" | "search"
| "quickaccess"; | "quickaccess"
| "daily";
+44 -4
View File
@@ -33,6 +33,19 @@
return resolved.join('/'); return resolved.join('/');
} }
const isAndroid = /android/i.test(navigator.userAgent);
function openLocalFile(path: string) {
if (isAndroid) {
const bridge = (window as any).Android;
if (bridge && typeof bridge.openFile === 'function') {
bridge.openFile(path);
}
} else {
openFile(path).catch((err) => console.error('Failed to open file:', err));
}
}
function resolveAndHandleLink(href: string) { function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err)); openUrl(href).catch((err) => console.error('Failed to open URL:', err));
@@ -42,6 +55,10 @@
const vaultRoot = config?.active_vault; const vaultRoot = config?.active_vault;
let absPath = decoded; let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) { if (!decoded.startsWith('/') && vaultRoot) {
// .helixnotes/ paths are always relative to vault root, not the note's directory
if (decoded.startsWith('.helixnotes/')) {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
} else {
const notePath = get(activeNotePath); const notePath = get(activeNotePath);
if (notePath) { if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
@@ -50,6 +67,7 @@
absPath = normalizePath(`${vaultRoot}/${decoded}`); absPath = normalizePath(`${vaultRoot}/${decoded}`);
} }
} }
}
// Internal .md note link — navigate within the app // Internal .md note link — navigate within the app
if (absPath.endsWith('.md')) { if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => { readNote(absPath).then((content) => {
@@ -57,7 +75,7 @@
activeNotePath.set(absPath); activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err)); }).catch((err) => console.error('Failed to navigate to note:', err));
} else { } else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err)); openLocalFile(absPath);
} }
} }
} }
@@ -76,7 +94,17 @@
// Intercept all link clicks in capture phase to prevent webview navigation // Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => { onMount(() => {
function handleLinkClick(e: MouseEvent) { function handleLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest('a'); const el = e.target as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
@@ -97,10 +125,22 @@
touchStartY = e.touches[0]?.clientY ?? 0; touchStartY = e.touches[0]?.clientY ?? 0;
} }
function handleTouchEnd(e: TouchEvent) { function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0; const touch = e.changedTouches[0];
if (!touch) return;
const endY = touch.clientY;
// If the finger moved > 10px, it's a scroll, not a tap // If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return; if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a'); // Android WebView may report e.target as the editor root for elements
// inside contenteditable="false" blocks; use elementFromPoint instead
const el = (document.elementFromPoint(touch.clientX, touch.clientY) ?? e.target) as HTMLElement;
// PDF/attachment embeds: check for data-open-file on target or ancestors
const fileTarget = el?.closest('[data-open-file]') as HTMLElement | null;
if (fileTarget) {
e.preventDefault();
openLocalFile(fileTarget.getAttribute('data-open-file')!);
return;
}
const target = el?.closest('a');
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
+2 -2
View File
@@ -10,8 +10,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
} },
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //