Compare commits

..
43 Commits
Author SHA1 Message Date
Yuri Karamian 7624c42e02 v1.2.8 - fix url links, tab indent, todo state, graph rework, collapsible sections, nav history, page breaks, folder/tag display 2026-03-27 14:05:59 +01:00
Yuri Karamian d46012b650 update README.md v1.2.7 2026-03-19 21:34:52 +01:00
Yuri Karamian 5d11ae6ea0 update README.md 2026-03-19 20:45:53 +01:00
Yuri Karamian bc372993c4 v1.2.7 - prefix search, keyboard shortcut fixes, GNOME AppImage fix 2026-03-19 19:19:26 +01:00
Yuri Karamian 4897703111 update README.md 2026-03-19 01:39:20 +01:00
Yuri Karamian 1bc067d470 v1.2.6 - unfiled notes, keyboard shortcuts, Android fixes 2026-03-19 00:36:42 +01:00
Yuri Karamian 565688b284 Restore trashed notebooks to their original location instead of vault root 2026-03-17 22:48:24 +01:00
Yuri Karamian e87a2edc00 Add keyboard shortcuts for sidebar, theme, focus mode, read-only, and fullscreen toggles 2026-03-17 22:37:48 +01:00
Yuri Karamian 9f595212dd add unfiled notes 2026-03-17 21:45:51 +01:00
Yuri Karamian 79f16b454a update README.md 2026-03-12 01:06:53 +01:00
Yuri Karamian 7be1b56b7c v1.2.5 - calendar view for daily notes, trash notebook restore, attachment/link fixes 2026-03-12 00:12:45 +01:00
Yuri Karamian 0b17a7c458 update README.md 2026-03-09 18:03:50 +01:00
Yuri Karamian 6c2bc72fbb v1.2.4 - proxy external images through imgproxy protocol for WebKitGTK compat, hide copy/open buttons for external images 2026-03-09 15:58:58 +01:00
Yuri Karamian e67ec21319 update README.md 2026-03-08 19:13:01 +01:00
Yuri Karamian e87c8cbe1d update README.md 2026-03-06 21:49:44 +01:00
Yuri Karamian 4fac6d05a7 update README.md 2026-03-06 21:46:45 +01:00
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
Yuri Karamian 4d93696028 v1.2.1 - Internal note links, mobile improvements, window state persistence 2026-02-28 23:20:19 +01:00
Yuri Karamian 6891a9cdc5 fix http images 2026-02-27 01:09:06 +01:00
Yuri Karamian 1e7fb248b2 v1.2.0 - Update README.md 2026-02-27 00:55:52 +01:00
Yuri Karamian 19fd44b65d v1.2.0 - Image toolbar, graph view fixes, mobile header improvements 2026-02-27 00:23:59 +01:00
Yuri Karamian deeefaa849 v1.1.9 - Update README.md 2026-02-26 22:41:31 +01:00
Yuri Karamian 29bd00a265 v1.1.9 - Android version prep 2026-02-26 20:57:00 +01:00
Yuri Karamian 37a837a33a v1.1.9 - Android version prep 2026-02-26 20:20:24 +01:00
Yuri Karamian a26f047d2b v1.1.9 - Android version prep 2026-02-26 18:19:11 +01:00
Yuri Karamian 245935bab0 Update README.md to v1.1.8 2026-02-25 02:28:11 +01:00
Yuri Karamian 61fa9cf1d8 Fix Ollama AI menu visibility and cancel stale stream listeners on menu close 2026-02-25 02:05:30 +01:00
Yuri Karamian 1440bdcd90 v1.1.8 - Fix image paste persistence, save-flush on note switch, WebKitGTK clipboard fallback 2026-02-25 01:02:45 +01:00
Yuri Karamian 25d4cf7a9d v1.1.8 - Fix unsaved content lost when switching notes, fix blob image save race 2026-02-25 00:35:36 +01:00
Yuri Karamian 0b5db1340c v1.1.8 - Fix unsaved content lost when switching notes 2026-02-24 23:32:14 +01:00
Yuri Karamian d098066899 v1.1.7 - Fix Untitled note rename, Ollama support, replace Sonnet 4.5 with Sonnet 4.6 2026-02-24 22:15:32 +01:00
Yuri Karamian 9e2f347a4b Update README.md to v1.1.6 2026-02-23 12:34:32 +01:00
Yuri Karamian 3453d406fc v1.1.6 - Multi-window, single-instance file handling, line height setting 2026-02-23 11:16:16 +01:00
Yuri Karamian 73a53dcb85 Update README to v1.1.5, add screenshots 2026-02-21 23:40:18 +01:00
Yuri Karamian abac0985bc Update README to v1.1.5, add screenshots 2026-02-21 23:38:56 +01:00
Yuri Karamian af394c0b43 Update README to v1.1.5, add screenshots 2026-02-21 23:37:44 +01:00
73 changed files with 6345 additions and 1110 deletions
+33 -25
View File
@@ -1,11 +1,16 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.7-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download ## Download (v1.2.7)
### Linux ### Linux
@@ -21,21 +26,25 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
``` ```
#### AppImage (Arch, Fedora, openSUSE) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.4/HelixNotes_1.1.4_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.7/HelixNotes_1.2.7_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_amd64.deb) Ubuntu 22.04+ [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_x64-setup.exe) Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.4/HelixNotes_1.1.4_x64.dmg) — macOS 12+ (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.7/HelixNotes_1.2.7_android.apk)
--- ---
@@ -43,24 +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 (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
- **Themes** — light/dark mode with customizable accent colors and fonts
- **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)
@@ -68,7 +69,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3 - **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher) - **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
- **Platforms**: Linux (AppImage), Windows, macOS - **Platforms**: Linux (AppImage), Windows, macOS, Android
## Building from Source ## Building from Source
@@ -92,6 +93,13 @@ pnpm tauri dev
pnpm tauri build pnpm tauri build
``` ```
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Settings](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph View](https://cdn.helixnotes.com/assets/screenshots/screenshot-3.png)
![AI Actions](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
## License ## License
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE) [AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
@@ -0,0 +1,3 @@
- Internal note links ([[wiki-links]] open the linked note)
- Mobile UI improvements
- Window state persistence (desktop)
@@ -0,0 +1,12 @@
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
Features:
- Markdown editor with toolbar, slash commands, and code highlighting
- [[Wiki-links]] and graph view
- Full-text search
- Outline panel, daily notes, tags
- Math (KaTeX) and PDF preview
- AI writing tools (Ollama, Anthropic, OpenAI)
- Version history with diffs
- Obsidian import
- Themes, accent colors, fonts
@@ -0,0 +1 @@
Local markdown note-taking app. No cloud, no lock-in.
@@ -0,0 +1 @@
HelixNotes
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.1.5", "version": "1.2.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+463 -109
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.5" version = "1.2.8"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -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,11 +35,14 @@ 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"] }
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+5 -1
View File
@@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "HelixNotes default capabilities", "description": "HelixNotes default capabilities",
"windows": ["main"], "windows": ["main", "note-*"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
@@ -12,6 +12,10 @@
"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:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
+2 -1
View File
@@ -5,6 +5,7 @@
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"updater:default" "updater:default",
"window-state:default"
] ]
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 953 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 B

After

Width:  |  Height:  |  Size: 1023 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

+52 -21
View File
@@ -6,6 +6,7 @@ use crate::types::AiStreamEvent;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages"; const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions"; const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
pub fn ai_request( pub fn ai_request(
app: AppHandle, app: AppHandle,
@@ -15,6 +16,7 @@ pub fn ai_request(
system_prompt: String, system_prompt: String,
user_message: String, user_message: String,
request_id: String, request_id: String,
base_url: Option<String>,
) { ) {
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
@@ -23,7 +25,22 @@ pub fn ai_request(
"openai" => { "openai" => {
stream_openai( stream_openai(
&app, &app,
&api_key, OPENAI_API_URL,
Some(&api_key),
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
"ollama" => {
let url = base_url.as_deref().unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
stream_openai(
&app,
&url,
None,
&model, &model,
&system_prompt, &system_prompt,
&user_message, &user_message,
@@ -188,7 +205,8 @@ async fn stream_anthropic(
async fn stream_openai( async fn stream_openai(
app: &AppHandle, app: &AppHandle,
api_key: &str, url: &str,
api_key: Option<&str>,
model: &str, model: &str,
system_prompt: &str, system_prompt: &str,
user_message: &str, user_message: &str,
@@ -224,10 +242,15 @@ async fn stream_openai(
body["temperature"] = json!(0.7); body["temperature"] = json!(0.7);
} }
let response = client let mut req = client
.post(OPENAI_API_URL) .post(url)
.header("Authorization", format!("Bearer {}", api_key)) .header("content-type", "application/json");
.header("content-type", "application/json")
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body) .json(&body)
.send() .send()
.await .await
@@ -325,9 +348,19 @@ async fn stream_openai(
Ok(()) Ok(())
} }
pub async fn test_connection(provider: &str, api_key: &str, model: &str) -> Result<String, String> { pub async fn test_connection(
provider: &str,
api_key: &str,
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
match provider { match provider {
"openai" => test_openai(api_key, model).await, "openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
test_openai(&url, None, model).await
}
_ => test_anthropic(api_key, model).await, _ => test_anthropic(api_key, model).await,
} }
} }
@@ -365,19 +398,12 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
} }
} }
async fn test_openai(api_key: &str, model: &str) -> Result<String, String> { async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({ let body = json!({
"model": model, "model": model,
token_key: 10, "max_tokens": 10,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -386,10 +412,15 @@ async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
] ]
}); });
let response = client let mut req = client
.post(OPENAI_API_URL) .post(url)
.header("Authorization", format!("Bearer {}", api_key)) .header("content-type", "application/json");
.header("content-type", "application/json")
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body) .json(&body)
.send() .send()
.await .await
+351 -34
View File
@@ -11,9 +11,21 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?; operations::ensure_vault_structure(&path)?;
// Initialize search index // Initialize search index — rebuild in background on Android (FUSE is slow)
let search = SearchIndex::new(&path)?; let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")]
{
let search_bg = search.clone();
let vault = path.clone();
std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault);
log::info!("Android: search index rebuild complete");
});
}
#[cfg(not(target_os = "android"))]
{
search.rebuild(&path)?; search.rebuild(&path)?;
}
*state.search_index.lock().map_err(|e| e.to_string())? = Some(search); *state.search_index.lock().map_err(|e| e.to_string())? = Some(search);
// Start file watcher // Start file watcher
@@ -45,6 +57,12 @@ pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
Ok(config.clone()) Ok(config.clone())
} }
#[tauri::command]
pub fn get_pending_open_file(state: State<'_, AppState>) -> Result<Option<String>, String> {
let mut pending = state.pending_open_file.lock().map_err(|e| e.to_string())?;
Ok(pending.take())
}
#[tauri::command] #[tauri::command]
pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> { pub fn set_theme(state: State<'_, AppState>, theme: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -77,6 +95,14 @@ pub fn set_font_family(state: State<'_, AppState>, family: String) -> Result<(),
Ok(()) Ok(())
} }
#[tauri::command]
pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.line_height = Some(height);
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ── // ── Notebooks ──
#[tauri::command] #[tauri::command]
@@ -86,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>,
@@ -175,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 ──
@@ -222,6 +257,14 @@ pub fn save_note(
drop(config); drop(config);
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -246,10 +289,10 @@ pub fn create_note(
} }
#[tauri::command] #[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String> { pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path)?; let entry = operations::create_daily_note(vault_path, date.as_deref())?;
if let Ok(search_guard) = state.search_index.lock() { if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard { if let Some(ref search) = *search_guard {
@@ -261,8 +304,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]
@@ -374,6 +420,163 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
Ok(entries) Ok(entries)
} }
// ── Graph ──
#[tauri::command]
pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphData, String> {
use std::collections::{HashMap, HashSet};
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 = std::path::Path::new(vault_path);
// Pass 1: collect ALL notes as nodes (no title deduplication — every note must appear)
let mut graph_nodes = Vec::new();
// title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new();
for entry in walkdir::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().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(".sync-conflict-") { continue; }
}
// Deduplicate by canonical path (handles symlinks)
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let canonical_str = canonical.to_string_lossy().to_string();
if !seen_paths.insert(canonical_str) { continue; }
let raw = std::fs::read_to_string(path).unwrap_or_default();
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
let title = extract_title_fast(&raw).unwrap_or_else(|| {
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
});
let idx = graph_nodes.len();
let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode {
title,
path: path_str,
});
contents.push(raw);
}
// Pass 2: extract edges from wiki-links (inline scan, no regex)
// edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists — mark it as bidirectional
edges[rev_idx].bidirectional = true;
} else {
let idx = edges.len();
edge_map.insert((src, tgt), idx);
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
}
};
for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 1 < len {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
i += 2;
let start = i;
while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') {
i += 1;
}
if i + 1 < len {
let link_raw = &body[start..i];
// Strip |alias, #heading, ^block
let link = link_raw.split('|').next().unwrap_or(link_raw);
let link = link.split('#').next().unwrap_or(link);
let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase();
// 1. Try vault-relative path match (e.g. "arkhost/note name")
if let Some(&target_idx) = relpath_to_idx.get(&link) {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
} else if let Some(targets) = title_to_idxs.get(&link) {
// 2. Title match — connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
}
}
}
i += 2;
}
} else {
i += 1;
}
}
}
Ok(crate::types::GraphData { nodes: graph_nodes, edges })
}
/// Fast title extraction from frontmatter without full YAML parsing.
/// Scans for `title: ...` line within `---` fences.
fn extract_title_fast(raw: &str) -> Option<String> {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") { return None; }
// Find the closing ---
let after_open = &trimmed[3..];
let end = after_open.find("\n---")?;
let frontmatter = &after_open[..end];
for line in frontmatter.lines() {
let line = line.trim();
if line.starts_with("title:") {
let val = line[6..].trim();
// Strip surrounding quotes
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
return Some(val[1..val.len()-1].to_string());
}
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
// ── Search ── // ── Search ──
#[tauri::command] #[tauri::command]
@@ -403,10 +606,10 @@ pub fn reindex(state: State<'_, AppState>) -> Result<(), String> {
// ── Trash ── // ── Trash ──
#[tauri::command] #[tauri::command]
pub fn get_trash(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> { pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::get_trash_notes(vault_path) operations::get_trash_contents(vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -415,14 +618,29 @@ pub fn restore_note(
trash_path: String, trash_path: String,
dest_notebook: Option<String>, dest_notebook: Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
let 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]
@@ -448,6 +666,68 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
operations::save_vault_state(vault_path, &vault_state) operations::save_vault_state(vault_path, &vault_state)
} }
// ── Clipboard ──
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Clipboard init failed: {}", e))?;
let img = clipboard
.get_image()
.map_err(|_| "No image on clipboard".to_string())?;
// Encode RGBA data to PNG
let mut buf: Vec<u8> = Vec::new();
{
let mut encoder =
png::Encoder::new(std::io::Cursor::new(&mut buf), img.width as u32, img.height as u32);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder
.write_header()
.map_err(|e| format!("PNG header failed: {}", e))?;
writer
.write_image_data(&img.bytes)
.map_err(|e| format!("PNG encode failed: {}", e))?;
}
Ok(buf)
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string())
}
/// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ── // ── Attachments ──
#[tauri::command] #[tauri::command]
@@ -1125,34 +1405,54 @@ fn resolve_wiki_ref(
reference.to_string() reference.to_string()
} }
// ── Open file with system default handler ── // ── Open file/URL with system default handler ──
#[tauri::command] fn xdg_open(arg: &str) -> Result<(), String> {
pub fn open_file(path: String) -> Result<(), String> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
std::process::Command::new("xdg-open") let mut cmd = std::process::Command::new("xdg-open");
.arg(&path) cmd.arg(arg);
.spawn() // Clear AppImage environment so child processes find host binaries
.map_err(|e| format!("Failed to open {}: {}", path, e))?; // (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
std::process::Command::new("open") std::process::Command::new("open")
.arg(&path) .arg(arg)
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
std::process::Command::new("cmd") std::process::Command::new("cmd")
.args(["/C", "start", "", &path]) .args(["/C", "start", "", arg])
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
Ok(()) Ok(())
} }
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command] #[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> { pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?; std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
@@ -1320,11 +1620,13 @@ pub fn set_ai_settings(
api_key: Option<String>, api_key: Option<String>,
model: String, model: String,
writing_style: Option<String>, writing_style: Option<String>,
base_url: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
let key = api_key.filter(|k| !k.is_empty()); let key = api_key.filter(|k| !k.is_empty());
match provider.as_deref() { match provider.as_deref() {
Some("openai") => config.openai_api_key = key, Some("openai") => config.openai_api_key = key,
Some("ollama") => config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()),
_ => config.ai_api_key = key, _ => config.ai_api_key = key,
} }
config.ai_provider = provider; config.ai_provider = provider;
@@ -1336,27 +1638,36 @@ pub fn set_ai_settings(
#[tauri::command] #[tauri::command]
pub fn test_ai_connection(app: AppHandle) -> Result<(), String> { pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
let (provider, api_key, model) = { let (provider, api_key, model, base_url) = {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let provider = config let provider = config
.ai_provider .ai_provider
.clone() .clone()
.unwrap_or_else(|| "anthropic".to_string()); .unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "openai" { let key = if provider == "ollama" {
// Ollama doesn't require an API key
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone() config.openai_api_key.clone()
} else { } else {
config.ai_api_key.clone() config.ai_api_key.clone()
} }
.ok_or("No API key configured")?; .ok_or("No API key configured")?;
let model = config.ai_model.clone(); let model = config.ai_model.clone();
(provider, key, model) let base_url = config.ollama_base_url.clone();
(provider, key, model, base_url)
}; };
std::thread::spawn(move || { std::thread::spawn(move || {
use tauri::Emitter; use tauri::Emitter;
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
let result = rt.block_on(crate::ai::test_connection(&provider, &api_key, &model)); let result = rt.block_on(crate::ai::test_connection(
&provider,
&api_key,
&model,
base_url.as_deref(),
));
match result { match result {
Ok(msg) => { Ok(msg) => {
let _ = app.emit( let _ = app.emit(
@@ -1383,14 +1694,16 @@ pub fn ai_ask(
custom_prompt: Option<String>, custom_prompt: Option<String>,
request_id: String, request_id: String,
) -> Result<(), String> { ) -> Result<(), String> {
let (provider, api_key, model, writing_style) = { let (provider, api_key, model, writing_style, base_url) = {
let state = app.state::<AppState>(); let state = app.state::<AppState>();
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let provider = config let provider = config
.ai_provider .ai_provider
.clone() .clone()
.unwrap_or_else(|| "anthropic".to_string()); .unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "openai" { let key = if provider == "ollama" {
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone() config.openai_api_key.clone()
} else { } else {
config.ai_api_key.clone() config.ai_api_key.clone()
@@ -1398,7 +1711,8 @@ pub fn ai_ask(
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?; .ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
let model = config.ai_model.clone(); let model = config.ai_model.clone();
let style = config.ai_writing_style.clone(); let style = config.ai_writing_style.clone();
(provider, key, model, style) let base_url = config.ollama_base_url.clone();
(provider, key, model, style, base_url)
}; };
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \ let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
@@ -1444,6 +1758,7 @@ pub fn ai_ask(
system_prompt, system_prompt,
user_message, user_message,
request_id, request_id,
base_url,
); );
Ok(()) Ok(())
} }
@@ -1494,11 +1809,13 @@ pub fn get_install_type() -> String {
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() { } else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
"deb".to_string() "deb".to_string()
} else if std::path::Path::new("/var/lib/pacman/local").exists() } else if std::path::Path::new("/var/lib/pacman/local").exists()
&& std::process::Command::new("pacman") && ["helixnotes", "helixnotes-bin", "helixnotes-appimage-bin"].iter().any(|pkg| {
.args(["-Q", "helixnotes"]) std::process::Command::new("pacman")
.args(["-Q", pkg])
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false)
})
{ {
"aur".to_string() "aur".to_string()
} else if std::env::var("APPIMAGE").is_ok() { } else if std::env::var("APPIMAGE").is_ok() {
+170 -8
View File
@@ -9,7 +9,7 @@ mod vault;
use state::AppState; use state::AppState;
#[allow(unused_imports)] #[allow(unused_imports)]
use tauri::Manager; use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use tauri::{ use tauri::{
@@ -44,7 +44,7 @@ pub fn run() {
)?; )?;
} }
// On Android, set config dir from Tauri's path resolver // On Android, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
{ {
if let Ok(config_dir) = app.path().config_dir() { if let Ok(config_dir) = app.path().config_dir() {
@@ -52,6 +52,11 @@ pub fn run() {
} else if let Ok(data_dir) = app.path().data_dir() { } else if let Ok(data_dir) = app.path().data_dir() {
commands::set_android_config_dir(data_dir); commands::set_android_config_dir(data_dir);
} }
// Reload config now that the Android config dir is available
let reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded;
});
} }
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -59,6 +64,28 @@ pub fn run() {
setup_tray(app)?; setup_tray(app)?;
} }
// Check CLI args for a .md file path on initial launch
#[cfg(not(target_os = "android"))]
{
let file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_arg {
let resolved = if std::path::Path::new(&path).is_absolute() {
std::path::PathBuf::from(&path)
} else {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
});
}
}
}
Ok(()) Ok(())
}) })
.manage(app_state) .manage(app_state)
@@ -69,7 +96,9 @@ pub fn run() {
commands::set_accent_color, commands::set_accent_color,
commands::set_font_size, commands::set_font_size,
commands::set_font_family, commands::set_font_family,
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,
@@ -84,14 +113,18 @@ pub fn run() {
commands::move_note, commands::move_note,
commands::get_all_tags, commands::get_all_tags,
commands::get_all_note_titles, commands::get_all_note_titles,
commands::get_graph_data,
commands::search_notes, commands::search_notes,
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,
commands::save_vault_state, commands::save_vault_state,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image, commands::save_image,
commands::save_attachment, commands::save_attachment,
commands::get_notebook_icons, commands::get_notebook_icons,
@@ -104,6 +137,7 @@ pub fn run() {
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url,
commands::copy_file_to, commands::copy_file_to,
commands::create_backup, commands::create_backup,
commands::list_backups, commands::list_backups,
@@ -117,28 +151,136 @@ pub fn run() {
commands::test_ai_connection, commands::test_ai_connection,
commands::ai_ask, commands::ai_ask,
commands::get_install_type, commands::get_install_type,
]); 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"))]
{ {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
let _ = window.unminimize(); let _ = window.unminimize();
let _ = window.set_focus(); let _ = window.set_focus();
} }
// Extract .md file path from args (args[0] is the binary)
let file_path = args.iter().skip(1).find(|arg| {
let a = arg.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_path {
let resolved = if std::path::Path::new(path.as_str()).is_absolute() {
std::path::PathBuf::from(path)
} else {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
let _ = app.emit("open-file", resolved_str.to_string());
}
}
})); }));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
if close_to_tray { builder = builder.on_window_event(move |window, event| {
builder = builder.on_window_event(|window, event| { match event {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
if close_to_tray && window.label() == "main" {
api.prevent_close(); api.prevent_close();
let _ = window.hide(); let _ = window.hide();
} }
});
} }
tauri::WindowEvent::Destroyed => {
// When main window is destroyed, close all note windows
if window.label() == "main" {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
let _ = win.close();
}
}
}
}
_ => {}
}
});
} }
builder builder
@@ -185,3 +327,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(()) Ok(())
} }
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+39 -14
View File
@@ -9,9 +9,9 @@ use tantivy::collector::TopDocs;
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
use tantivy::directory::RamDirectory; use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; use walkdir::WalkDir;
pub struct SearchIndex { pub struct SearchIndex {
@@ -77,18 +77,20 @@ impl SearchIndex {
for entry in WalkDir::new(vault_path) for entry in WalkDir::new(vault_path)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_entry(|e| {
.filter(|e| {
let p = e.path(); let p = e.path();
p.is_file() !p.starts_with(&hn_dir)
&& p.extension().and_then(|x| x.to_str()) == Some("md")
&& !p.starts_with(&hn_dir)
&& !p && !p
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.map(|n| n.starts_with('.')) .map(|n| n.starts_with('.'))
.unwrap_or(false) .unwrap_or(false)
}) })
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{ {
if let Ok(raw) = fs::read_to_string(entry.path()) { if let Ok(raw) = fs::read_to_string(entry.path()) {
let filename = entry.file_name().to_string_lossy().to_string(); let filename = entry.file_name().to_string_lossy().to_string();
@@ -150,14 +152,37 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let fields = vec![self.title_field, self.body_field, self.tags_field];
&self.index, let terms: Vec<String> = query_str
vec![self.title_field, self.body_field, self.tags_field], .split_whitespace()
); .map(|t| t.to_lowercase())
.collect();
let query = query_parser // For each search term, create prefix + fuzzy queries across all fields (OR),
.parse_query(query_str) // then AND all terms together
.map_err(|e| e.to_string())?; let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit)) .search(&query, &TopDocs::with_limit(limit))
+4 -1
View File
@@ -3,12 +3,14 @@ use crate::types::AppConfig;
use notify::RecommendedWatcher; use notify::RecommendedWatcher;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::Arc;
pub struct AppState { pub struct AppState {
pub config: Mutex<AppConfig>, pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<SearchIndex>>, pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>, pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool, pub importing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
} }
impl AppState { impl AppState {
@@ -18,6 +20,7 @@ impl AppState {
search_index: Mutex::new(None), search_index: Mutex::new(None),
watcher: Mutex::new(None), watcher: Mutex::new(None),
importing: AtomicBool::new(false), importing: AtomicBool::new(false),
pending_open_file: Mutex::new(None),
} }
} }
} }
+41 -2
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,
@@ -54,6 +68,8 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub font_family: Option<String>, pub font_family: Option<String>,
#[serde(default)] #[serde(default)]
pub line_height: Option<f64>,
#[serde(default)]
pub compact_notes: bool, pub compact_notes: bool,
#[serde(default)] #[serde(default)]
pub time_format: String, pub time_format: String,
@@ -91,6 +107,8 @@ pub struct AppConfig {
pub ai_api_key: Option<String>, pub ai_api_key: Option<String>,
#[serde(default)] #[serde(default)]
pub openai_api_key: Option<String>, pub openai_api_key: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default = "default_ai_model")] #[serde(default = "default_ai_model")]
pub ai_model: String, pub ai_model: String,
#[serde(default)] #[serde(default)]
@@ -130,7 +148,7 @@ fn default_max_versions() -> u32 {
} }
fn default_ai_model() -> String { fn default_ai_model() -> String {
"claude-sonnet-4-5-20250929".to_string() "claude-sonnet-4-6".to_string()
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -142,6 +160,7 @@ impl Default for AppConfig {
accent_color: None, accent_color: None,
font_size: None, font_size: None,
font_family: None, font_family: None,
line_height: None,
compact_notes: false, compact_notes: false,
time_format: "relative".to_string(), time_format: "relative".to_string(),
gpu_acceleration: true, gpu_acceleration: true,
@@ -161,7 +180,8 @@ impl Default for AppConfig {
ai_provider: None, ai_provider: None,
ai_api_key: None, ai_api_key: None,
openai_api_key: None, openai_api_key: None,
ai_model: "claude-sonnet-4-5-20250929".to_string(), ollama_base_url: None,
ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None, ai_writing_style: None,
default_view_mode: false, default_view_mode: false,
show_tray_icon: false, show_tray_icon: false,
@@ -248,3 +268,22 @@ pub struct NoteTitleEntry {
pub title: String, pub title: String,
pub path: String, pub path: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: usize,
pub target: usize,
pub bidirectional: bool,
}
+27 -2
View File
@@ -77,6 +77,29 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
(meta, content) (meta, content)
} }
/// Ensure the body starts with `# Title` heading for external app compatibility.
/// If the body already starts with `# Title`, leave it as-is.
fn ensure_title_heading(body: &str, title: &str) -> String {
let trimmed = body.trim_start();
// Check if body already starts with `# Title`
if let Some(rest) = trimmed.strip_prefix("# ") {
let first_line_end = rest.find('\n').unwrap_or(rest.len());
let heading_text = rest[..first_line_end].trim();
if heading_text.eq_ignore_ascii_case(title.trim()) {
return body.to_string();
}
}
// Don't add heading to empty notes (new/blank notes)
if trimmed.is_empty() {
return body.to_string();
}
// Prepend `# Title` heading
format!("# {}\n\n{}", title, body)
}
pub fn serialize_frontmatter(meta: &NoteMeta) -> String { pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
let tags_str = if meta.tags.is_empty() { let tags_str = if meta.tags.is_empty() {
"[]".to_string() "[]".to_string()
@@ -104,7 +127,8 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String { pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
let fm = serialize_frontmatter(meta); let fm = serialize_frontmatter(meta);
format!("{}{}", fm, body) let body_with_heading = ensure_title_heading(body, &meta.title);
format!("{}{}", fm, body_with_heading)
} }
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys. /// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
@@ -163,7 +187,8 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
Err(_) => return update_note_raw(meta, body), Err(_) => return update_note_raw(meta, body),
}; };
format!("---\n{}---\n{}", yaml_str, body) let body_with_heading = ensure_title_heading(body, &meta.title);
format!("---\n{}---\n{}", yaml_str, body_with_heading)
} }
fn filename_to_title(filename: &str) -> String { fn filename_to_title(filename: &str) -> String {
+475 -33
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;
@@ -59,11 +59,13 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
return entries; return entries;
}; };
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
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| {
let path = e.path(); e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
path.is_dir() && !is_hidden(&path) && !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
}) })
.collect(); .collect();
@@ -74,6 +76,8 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.cmp(&b.file_name().to_string_lossy().to_lowercase()) .cmp(&b.file_name().to_string_lossy().to_lowercase())
}); });
// Store note_count for the parent directory (used when this is the top-level call)
// Each directory pushes its own entry with its own note_count
for dir_entry in dirs { for dir_entry in dirs {
let path = dir_entry.path(); let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string(); let name = dir_entry.file_name().to_string_lossy().to_string();
@@ -83,32 +87,71 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let children = scan_dir_recursive(&path, vault_root); let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
let note_count = count_notes_in_dir(&path);
entries.push(NotebookEntry { entries.push(NotebookEntry {
name, name,
path: path.to_string_lossy().to_string(), path: path.to_string_lossy().to_string(),
relative_path: relative, relative_path: relative,
children, children,
note_count, note_count: child_note_count,
}); });
} }
entries entries
} }
fn count_notes_in_dir(dir: &Path) -> usize { /// Combined scan: returns (children, note_count) in a single read_dir pass.
fs::read_dir(dir) fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usize) {
.map(|rd| { let root = Path::new(vault_root);
rd.filter_map(|e| e.ok())
.filter(|e| { let Ok(read_dir) = fs::read_dir(dir) else {
let path = e.path(); return (Vec::new(), 0);
path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md") };
})
.count() let mut subdirs = Vec::new();
}) let mut note_count = 0usize;
.unwrap_or(0) for entry in read_dir.filter_map(|e| e.ok()) {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if ft.is_dir() && !is_hidden(&entry.path()) {
subdirs.push(entry);
} else if ft.is_file() && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
note_count += 1;
}
}
subdirs.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.to_lowercase()
.cmp(&b.file_name().to_string_lossy().to_lowercase())
});
let mut entries = Vec::new();
for dir_entry in subdirs {
let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
entries.push(NotebookEntry {
name,
path: path.to_string_lossy().to_string(),
relative_path: relative,
children,
note_count: child_note_count,
});
}
(entries, note_count)
} }
fn is_hidden(path: &Path) -> bool { fn is_hidden(path: &Path) -> bool {
@@ -124,17 +167,85 @@ 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);
let vault_root = Path::new(vault_path); let vault_root = Path::new(vault_path);
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
if !root.exists() { if !root.exists() {
return Err("Path does not exist".to_string()); return Err("Path does not exist".to_string());
} }
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
#[cfg(target_os = "android")]
{
let mut notes = Vec::new();
// Log what read_dir sees
if let Ok(rd) = fs::read_dir(root) {
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
for (i, entry) in items.iter().enumerate().take(5) {
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
}
} else {
log::info!("scan_notes: read_dir FAILED for {:?}", root);
}
if notebook_path.is_some() {
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.filter_map(|e| e.ok()) {
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
notes.push(note);
}
}
}
}
} else {
let hn_dir = helixnotes_dir(vault_path);
for entry in WalkDir::new(root)
.into_iter()
// filter_entry skips descending into hidden dirs (unlike filter)
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
notes.push(note);
}
}
}
log::info!("scan_notes: Android scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
#[cfg(not(target_os = "android"))]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() { let md_files: Vec<PathBuf> = if notebook_path.is_some() {
// Only direct children for a specific notebook
fs::read_dir(root) fs::read_dir(root)
.map_err(|e| e.to_string())? .map_err(|e| e.to_string())?
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
@@ -142,17 +253,15 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect() .collect()
} else { } else {
// Recursive for "All Notes"
WalkDir::new(root) WalkDir::new(root)
.into_iter() .into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.map(|e| e.path().to_path_buf()) .map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) .filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect() .collect()
}; };
// Process files in parallel with partial reads
let mut notes: Vec<NoteEntry> = md_files let mut notes: Vec<NoteEntry> = md_files
.par_iter() .par_iter()
.filter_map(|path| read_note_entry_fast(path, vault_root).ok()) .filter_map(|path| read_note_entry_fast(path, vault_root).ok())
@@ -160,6 +269,7 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
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) Ok(notes)
}
} }
fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> { fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
@@ -167,6 +277,51 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
read_note_entry_from_str(&raw, path, vault_root) read_note_entry_from_str(&raw, path, vault_root)
} }
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB — enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(bytes_read);
let raw = String::from_utf8_lossy(&buf);
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) {
if let Ok(m) = fs_meta.modified() {
meta.modified = m.into();
}
if let Ok(c) = fs_meta.created() {
meta.created = c.into();
}
}
let relative = path
.strip_prefix(vault_root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry {
path: path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview,
})
}
/// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview). /// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview).
fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> { fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
let mut file = fs::File::open(path).map_err(|e| e.to_string())?; let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
@@ -314,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())?;
@@ -432,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()
@@ -450,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() {
@@ -466,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> {
@@ -534,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())?;
@@ -550,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(
@@ -582,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.1.5", "version": "1.2.8",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
@@ -24,7 +24,7 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost 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;
}
}
+38 -4
View File
@@ -7,6 +7,7 @@ import type {
NotebookEntry, NotebookEntry,
NoteTitleEntry, NoteTitleEntry,
SearchResult, SearchResult,
TrashContents,
VaultState, VaultState,
VaultStats, VaultStats,
ImportResult, ImportResult,
@@ -38,10 +39,18 @@ export async function setFontFamily(family: string): Promise<void> {
return invoke("set_font_family", { family }); return invoke("set_font_family", { family });
} }
export async function setLineHeight(height: number): Promise<void> {
return invoke("set_line_height", { height });
}
export async function getNotebooks(): Promise<NotebookEntry[]> { 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,
@@ -92,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(
@@ -122,6 +131,10 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles"); return invoke("get_all_note_titles");
} }
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
return invoke("get_graph_data");
}
export async function searchNotes( export async function searchNotes(
query: string, query: string,
limit?: number, limit?: number,
@@ -133,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");
} }
@@ -144,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 });
} }
@@ -160,6 +177,14 @@ export async function saveVaultState(vaultState: VaultState): Promise<void> {
return invoke("save_vault_state", { vaultState }); return invoke("save_vault_state", { vaultState });
} }
export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image");
}
export async function copyImageToClipboard(path: string): Promise<void> {
return invoke("copy_image_to_clipboard", { path });
}
export async function saveImage(name: string, data: number[]): Promise<string> { export async function saveImage(name: string, data: number[]): Promise<string> {
return invoke("save_image", { name, data }); return invoke("save_image", { name, data });
} }
@@ -242,6 +267,10 @@ export async function openFile(path: string): Promise<void> {
return invoke("open_file", { path }); return invoke("open_file", { path });
} }
export async function openUrl(url: string): Promise<void> {
return invoke("open_url", { url });
}
export async function copyFileTo( export async function copyFileTo(
source: string, source: string,
destination: string, destination: string,
@@ -310,8 +339,9 @@ export async function setAiSettings(
apiKey: string | null, apiKey: string | null,
model: string, model: string,
writingStyle: string | null, writingStyle: string | null,
baseUrl: string | null = null,
): Promise<void> { ): Promise<void> {
return invoke("set_ai_settings", { provider, apiKey, model, writingStyle }); return invoke("set_ai_settings", { provider, apiKey, model, writingStyle, baseUrl });
} }
export async function testAiConnection(): Promise<void> { export async function testAiConnection(): Promise<void> {
@@ -330,3 +360,7 @@ export async function aiAsk(
export async function getInstallType(): Promise<string> { export async function getInstallType(): Promise<string> {
return invoke("get_install_type"); return invoke("get_install_type");
} }
export async function getPendingOpenFile(): Promise<string | null> {
return invoke("get_pending_open_file");
}
+327 -117
View File
@@ -28,14 +28,24 @@
showInfo, showInfo,
showSettings, showSettings,
sourceMode, sourceMode,
mobileView mobileView,
appConfig,
quickAccessPaths,
tags,
notes,
viewMode,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab,
navHistory
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup } 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 { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { VaultState, FileEvent } from '$lib/types'; import type { VaultState, FileEvent } from '$lib/types';
@@ -43,10 +53,14 @@
let noteList: NoteList; let noteList: NoteList;
let editor: Editor; let editor: Editor;
let unlistenFileChange: (() => void) | null = null; let unlistenFileChange: (() => void) | null = null;
let unlistenOpenFile: (() => void) | null = null;
// Mobile editor header helpers
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number { function parseFrequencyMs(freq: string): number {
switch (freq) { switch (freq) {
@@ -77,36 +91,41 @@
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
if (navigatingFromHistory) { if (path) navHistory.push(path);
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
}); });
function navigateHistory(direction: -1 | 1) { function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction; const path = navHistory.go(direction);
if (newIndex < 0 || newIndex >= noteHistory.length) return; if (!path) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
editor?.loadNote(path, content.content); editor?.loadNote(path, content.content);
}).catch(() => { }).catch(() => {});
// Note may have been deleted, ignore }
});
async function handleOpenFile(filePath: string) {
if (!filePath || !filePath.endsWith('.md')) return;
const config = get(appConfig);
const vaultRoot = config?.active_vault;
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return;
try {
const content = await readNote(filePath);
editor?.flushSave();
$activeNote = content;
$activeNotePath = filePath;
$editorDirty = false;
editor?.loadNote(filePath, content.content);
} catch (e) {
console.error('Failed to open file:', e);
}
} }
const persistState = debounce(async () => { const persistState = debounce(async () => {
const state: VaultState = { const state: VaultState = {
last_open_note: null, last_open_note: $activeNotePath,
sidebar_width: $sidebarWidth, sidebar_width: $sidebarWidth,
notelist_width: $notelistWidth, notelist_width: $notelistWidth,
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
@@ -133,12 +152,11 @@
} }
function handleViewChanged() { function handleViewChanged() {
noteList?.refresh(true); noteList?.refresh();
if (isMobile) $mobileView = 'notelist'; if (isMobile) $mobileView = 'notelist';
} }
async function createAndFocusNote() { async function createAndFocusNote() {
if (isMobile && $mobileView !== 'notelist') $mobileView = 'notelist';
await noteList?.handleCreateNote(); await noteList?.handleCreateNote();
editor?.focusTitle(); editor?.focusTitle();
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
@@ -148,11 +166,13 @@
try { try {
const entry = await createDailyNote(); const entry = await createDailyNote();
const content = await readNote(entry.path); const content = await readNote(entry.path);
editor?.flushSave();
$activeNote = content; $activeNote = content;
$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) {
@@ -160,6 +180,53 @@
} }
} }
// Android back gesture / hardware back button support
// We maintain a simple counter of how many views deep we are.
// sidebar=0, notelist=1, editor=2. Each forward nav pushes, back pops.
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) {
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
$effect(() => {
const view = $mobileView;
if (navFromPopstate) {
navFromPopstate = false;
return;
}
const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
if (targetDepth > historyDepth) {
// Forward navigation — push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, '');
}
historyDepth = targetDepth;
} else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth;
historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers
history.go(-steps);
}
});
window.addEventListener('popstate', (e) => {
if (navFromPopstate) {
navFromPopstate = false;
return;
}
const state = e.state;
const targetDepth = state?.depth ?? 0;
historyDepth = targetDepth;
navFromPopstate = true;
if (targetDepth === 0) $mobileView = 'sidebar';
else if (targetDepth === 1) $mobileView = 'notelist';
else $mobileView = 'editor';
});
}
function mobileBack() { function mobileBack() {
if ($mobileView === 'editor') $mobileView = 'notelist'; if ($mobileView === 'editor') $mobileView = 'notelist';
else if ($mobileView === 'notelist') $mobileView = 'sidebar'; else if ($mobileView === 'notelist') $mobileView = 'sidebar';
@@ -172,6 +239,7 @@
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; const mod = e.ctrlKey || e.metaKey;
const code = e.code;
if (e.altKey && e.key === 'ArrowLeft') { if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault(); e.preventDefault();
navigateHistory(-1); navigateHistory(-1);
@@ -182,19 +250,25 @@
navigateHistory(1); navigateHistory(1);
return; return;
} }
if (mod && !e.shiftKey && e.key === 'n') { if (mod && !e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
return;
} }
if (mod && e.shiftKey && e.key === 'N') { if (mod && e.shiftKey && code === 'KeyN') {
e.preventDefault(); e.preventDefault();
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const next = isDark ? 'light' : 'dark';
$theme = next;
setTheme(next);
return;
} }
if (mod && e.shiftKey && e.key === 'F') { if (mod && e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return; return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (mod && !e.shiftKey && code === 'KeyF') {
e.preventDefault(); e.preventDefault();
if ($activeNotePath) { if ($activeNotePath) {
editor?.openNoteSearch(); editor?.openNoteSearch();
@@ -203,22 +277,50 @@
} }
return; return;
} }
if (mod && e.key === 'p') { if (mod && !e.shiftKey && code === 'KeyP') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
return;
} }
if (mod && e.key === 's') { if (mod && !e.shiftKey && code === 'KeyS') {
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
return;
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) { if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault(); e.preventDefault();
editor?.addLinkFromToolbar(); editor?.addLinkFromToolbar();
} }
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && code === 'KeyM') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && code === 'KeyW') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
}
if (mod && e.key === '\\') {
e.preventDefault();
$sidebarCollapsed = !$sidebarCollapsed;
return;
}
if (mod && e.shiftKey && code === 'KeyE') {
e.preventDefault();
$focusMode = !$focusMode;
return;
}
if (mod && e.shiftKey && code === 'KeyR') {
e.preventDefault();
$readOnly = !$readOnly;
return;
}
if (e.key === 'F11') {
e.preventDefault();
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
return;
}
if (e.key === 'Escape') { if (e.key === 'Escape') {
if ($showSettings) $showSettings = false; if ($showSettings) $showSettings = false;
else if ($showInfo) $showInfo = false; else if ($showInfo) $showInfo = false;
@@ -245,28 +347,97 @@
persistState(); persistState();
}); });
$effect(() => {
$activeNotePath;
persistState();
});
onMount(async () => { onMount(async () => {
let lastNotePath: string | null = null;
try { try {
const state = await loadVaultState(); const state = await loadVaultState();
$sidebarWidth = state.sidebar_width; $sidebarWidth = state.sidebar_width;
$notelistWidth = state.notelist_width; $notelistWidth = state.notelist_width;
$sidebarCollapsed = state.sidebar_collapsed; $sidebarCollapsed = state.sidebar_collapsed;
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
lastNotePath = state.last_open_note ?? null;
} catch (_) {} } catch (_) {}
// On mobile, prefetch last-opened note so first tap is instant
let prefetchPromise: Promise<any> | null = null;
if (isMobile && lastNotePath) {
prefetchPromise = readNote(lastNotePath).catch(() => null);
}
applyTheme($theme); applyTheme($theme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if ($theme === 'system') applyTheme('system'); if ($theme === 'system') applyTheme('system');
}); });
await sidebar?.refresh(); // Run sidebar and note list refresh in parallel
await noteList?.refresh(); await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
unlistenFileChange = await listen<FileEvent>('file-changed', async () => { // On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
await sidebar?.refresh(); if (isMobile) {
await noteList?.refresh(true); const tagMap = new Map<string, number>();
for (const note of $notes) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
$tags = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}
// On mobile, auto-load the last-opened note so it's ready when the user taps it
if (isMobile && prefetchPromise) {
prefetchPromise.then((noteContent) => {
if (noteContent && lastNotePath && !$activeNotePath) {
$activeNote = noteContent;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, noteContent.content);
}
}); });
}
if (isMobile) {
// Mobile: debounce file-changed heavily and skip when actively editing
// (our own saves trigger the watcher, causing expensive refreshes on FUSE)
const debouncedRefresh = debounce(async () => {
if (get(editorDirty)) return; // user is still typing, skip
await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
// Re-derive tags from refreshed notes
const tagMap = new Map<string, number>();
for (const note of get(notes)) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
}, 3000);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedRefresh();
});
} else {
const debouncedDesktopRefresh = debounce(async () => {
await Promise.all([sidebar?.refresh(), noteList?.refresh(true)]);
}, 300);
unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedDesktopRefresh();
});
}
// Listen for file open events (from single-instance or OS file associations)
unlistenOpenFile = await listen<string>('open-file', async (event) => {
await handleOpenFile(event.payload);
});
// Check for pending file from first-launch CLI args
try {
const pending = await getPendingOpenFile();
if (pending) await handleOpenFile(pending);
} catch (_) {}
// Scheduled backup: check on startup and every 5 minutes // Scheduled backup: check on startup and every 5 minutes
checkScheduledBackup(); checkScheduledBackup();
@@ -275,6 +446,7 @@
onDestroy(() => { onDestroy(() => {
unlistenFileChange?.(); unlistenFileChange?.();
unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval); if (backupInterval) clearInterval(backupInterval);
}); });
</script> </script>
@@ -303,18 +475,23 @@
</svg> </svg>
</div> </div>
{/if} {/if}
{#if $mobileView !== 'editor'}
<span class="mobile-header-title"> <span class="mobile-header-title">
{#if $mobileView === 'sidebar'} {#if $mobileView === 'sidebar'}
HelixNotes HelixNotes
{:else if $mobileView === 'notelist'}
{$activeNotebook?.name || 'All Notes'}
{:else} {:else}
{$activeNote?.meta.title || 'Untitled'} {#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if} {/if}
</span> </span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
<button class="mobile-update-badge" onclick={() => { $settingsTab = 'updates'; $showSettings = true; }}>
v{$globalUpdateAvailable.version}
</button>
{/if}
{/if}
<div class="mobile-header-actions"> <div class="mobile-header-actions">
{#if $mobileView === 'editor'} {#if $mobileView === 'editor'}
<button class="mobile-header-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)}> <button class="mobile-header-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Edit' : 'View'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if $readOnly} {#if $readOnly}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
@@ -325,18 +502,70 @@
{/if} {/if}
</svg> </svg>
</button> </button>
{:else} <button class="mobile-header-btn" onclick={() => editor?.openNoteSearch()} title="Find in note">
<button class="mobile-header-btn" onclick={createAndFocusNote}> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
<path d="M12 5v14M5 12h14" /> </svg>
</button>
<button class="mobile-header-btn" class:active={$activeNote?.meta.pinned} onclick={() => { if ($activeNote) { $activeNote.meta.pinned = !$activeNote.meta.pinned; $editorDirty = true; } }} title="Pin">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
</svg>
</button>
<button class="mobile-header-btn" class:active={isQuickAccess} onclick={async () => {
if (!noteRelativePath) return;
try {
if (isQuickAccess) { await removeQuickAccess(noteRelativePath); } else { await addQuickAccess(noteRelativePath); }
$quickAccessPaths = (await getQuickAccess()).map(n => n.relative_path);
} catch (e) { console.error('Quick access toggle failed:', e); }
}} title="Quick Access">
<svg width="18" height="18" viewBox="0 0 24 24" fill={isQuickAccess ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.toggleOutlinePanel()} title="Outline">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="20" y2="6"/><line x1="8" y1="12" x2="20" y2="12"/><line x1="8" y1="18" x2="20" y2="18"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/>
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.toggleHistoryPanel()} title="History">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
</button>
{#if $appConfig?.enable_wiki_links}
<button class="mobile-header-btn" onclick={() => editor?.toggleGraphView()} title="Graph View">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/>
<line x1="8.5" y1="7.5" x2="15.5" y2="16.5"/><line x1="15.5" y1="7.5" x2="8.5" y2="16.5"/>
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info"> {#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
<button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" /> <path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
<line x1="12" y1="16" x2="12" y2="12" /> </svg>
<line x1="12" y1="8" x2="12.01" y2="8" /> </button>
{/if}
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</button>
{:else}
<button class="mobile-header-btn" onclick={() => ($showSearch = true)} title="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</button>
<button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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> </svg>
</button> </button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings"> <button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
@@ -345,6 +574,7 @@
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /> <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg> </svg>
</button> </button>
{/if}
</div> </div>
</div> </div>
@@ -354,47 +584,18 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
</div> </div>
</div> </div>
<!-- Mobile Bottom Nav -->
{#if $mobileView !== 'editor'}
<div class="mobile-bottom-nav">
<button class="mobile-nav-item" class:active={$mobileView === 'sidebar'} onclick={() => ($mobileView = 'sidebar')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
<span>Notebooks</span>
</button>
<button class="mobile-nav-item" onclick={() => $showSearch = true}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span>Search</span>
</button>
<button class="mobile-nav-item" onclick={handleDailyNote}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" 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</span>
</button>
<button class="mobile-nav-item" onclick={() => ($showSettings = true)}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
<span>Settings</span>
</button>
</div>
{/if}
</div> </div>
{:else} {:else}
<!-- ═══ DESKTOP LAYOUT ═══ --> <!-- ═══ DESKTOP LAYOUT ═══ -->
@@ -448,7 +649,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
@@ -597,25 +798,45 @@
padding: 0 4px; padding: 0 4px;
} }
.mobile-update-badge {
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.mobile-header-actions { .mobile-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px; gap: 2px;
margin-left: auto;
} }
.mobile-header-btn { .mobile-header-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 34px;
height: 44px; height: 34px;
border: none; border: none;
background: none; background: none;
color: var(--text-secondary); color: var(--text-secondary);
border-radius: 10px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.mobile-header-btn svg {
width: 16px;
height: 16px;
}
.mobile-header-btn:active { .mobile-header-btn:active {
background: var(--bg-hover); background: var(--bg-hover);
} }
@@ -645,38 +866,27 @@
pointer-events: auto; pointer-events: auto;
} }
.mobile-bottom-nav { .mobile-fab {
display: flex; position: absolute;
align-items: center; bottom: 24px;
justify-content: space-around; right: 20px;
width: 56px;
height: 56px; height: 56px;
background: var(--bg-secondary); border-radius: 16px;
border-top: 1px solid var(--border-color); background: var(--accent);
flex-shrink: 0; color: white;
padding-bottom: env(safe-area-inset-bottom);
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 16px;
border: none; border: none;
background: none; display: flex;
color: var(--text-tertiary); align-items: center;
font-size: 10px; justify-content: center;
font-weight: 500; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer; cursor: pointer;
border-radius: 8px; z-index: 10;
transition: color 0.15s;
} }
.mobile-nav-item:active { .mobile-fab:active {
background: var(--bg-hover); transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
} }
.mobile-nav-item.active {
color: var(--accent);
}
</style> </style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+42 -8
View File
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>('shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0'); getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -54,10 +55,12 @@
</div> </div>
<div class="info-body"> <div class="info-body">
{#if !isMobile}
<div class="info-tabs"> <div class="info-tabs">
<button class="info-tab" class:active={activeTab === 'shortcuts'} onclick={() => activeTab = 'shortcuts'}>Shortcuts</button> <button class="info-tab" class:active={activeTab === 'shortcuts'} onclick={() => activeTab = 'shortcuts'}>Shortcuts</button>
<button class="info-tab" class:active={activeTab === 'about'} onclick={() => activeTab = 'about'}>About</button> <button class="info-tab" class:active={activeTab === 'about'} onclick={() => activeTab = 'about'}>About</button>
</div> </div>
{/if}
{#if activeTab === 'about'} {#if activeTab === 'about'}
<div class="info-logo"> <div class="info-logo">
@@ -113,21 +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">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>
@@ -397,4 +416,19 @@
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: 1.4; line-height: 1.4;
} }
/* Mobile */
@media (max-width: 600px) {
.info-panel {
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: none;
}
.info-header {
padding-top: calc(env(safe-area-inset-top, 12px) + 12px);
}
}
</style> </style>
+618 -18
View File
@@ -12,7 +12,8 @@
quickAccessPaths, quickAccessPaths,
appConfig, appConfig,
notebooks, notebooks,
tags tags,
mobileView
} from '$lib/stores/app'; } from '$lib/stores/app';
import { import {
getNotes, getNotes,
@@ -23,24 +24,111 @@
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 type { NoteEntry, SortMode } from '$lib/types'; import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|ios/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
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) */
function deriveTagsFromNotes() {
const tagMap = new Map<string, number>();
for (const note of $notes) {
for (const tag of note.meta.tags) {
tagMap.set(tag, (tagMap.get(tag) ?? 0) + 1);
}
}
$tags = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}
async function refreshTags() {
if (isMobile) {
deriveTagsFromNotes();
} else {
await refreshTags();
}
}
let compact = $derived($appConfig?.compact_notes ?? false); let compact = $derived($appConfig?.compact_notes ?? false);
let contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null); let contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null);
@@ -58,6 +146,52 @@
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false); let batchTagEdit = $state(false);
// Mobile long-press selection
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressTriggered = false;
let touchStartPos = { x: 0, y: 0 };
const LONG_PRESS_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
function handleTouchStart(e: TouchEvent, note: NoteEntry) {
longPressTriggered = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
longPressTimer = setTimeout(() => {
longPressTriggered = true;
// Vibrate for haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
const next = new Set(selectedPaths);
if (next.size === 0 && $activeNotePath && $activeNotePath !== note.path) {
next.add($activeNotePath);
}
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
}, LONG_PRESS_MS);
}
function handleTouchMove(e: TouchEvent) {
if (!longPressTimer) return;
const touch = e.touches[0];
const dx = touch.clientX - touchStartPos.x;
const dy = touch.clientY - touchStartPos.y;
if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy) > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Quick Access drag-to-reorder // Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
@@ -86,6 +220,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
@@ -96,6 +231,15 @@
if (listContainer) listContainer.scrollTop = 0; if (listContainer) listContainer.scrollTop = 0;
}); });
// Invalidate quickaccess cache when starred notes change (e.g. from Editor star toggle)
$effect(() => {
$quickAccessPaths;
noteCache.delete('quickaccess');
if ($viewMode === 'quickaccess') {
refresh();
}
});
// Track container height via ResizeObserver // Track container height via ResizeObserver
$effect(() => { $effect(() => {
if (!listContainer) return; if (!listContainer) return;
@@ -134,6 +278,7 @@
if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook'; if ($viewMode === 'notebook') return $activeNotebook?.name ?? 'Notebook';
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -143,6 +288,7 @@
function cacheKey(): string { function cacheKey(): string {
if ($viewMode === 'trash') return 'trash'; if ($viewMode === 'trash') return 'trash';
if ($viewMode === 'daily') return 'daily';
if ($viewMode === 'quickaccess') return 'quickaccess'; if ($viewMode === 'quickaccess') return 'quickaccess';
if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`; if ($viewMode === 'tag' && $activeTag) return `tag:${$activeTag}`;
if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`; if ($viewMode === 'notebook' && $activeNotebook) return `nb:${$activeNotebook.path}`;
@@ -163,11 +309,33 @@
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) {
const all = await getNotes(null); // Reuse cached "all" notes if available, otherwise fetch and cache them
let all = noteCache.get('all');
if (!all) {
all = await getNotes(null);
noteCache.set('all', all);
}
$notes = all.filter((n) => n.meta.tags.includes($activeTag!)); $notes = all.filter((n) => n.meta.tags.includes($activeTag!));
} else { } else {
const nbPath = $viewMode === 'notebook' ? $activeNotebook?.path ?? null : null; const nbPath = $viewMode === 'notebook' ? $activeNotebook?.path ?? null : null;
@@ -180,9 +348,13 @@
} }
async function selectNote(note: NoteEntry) { async function selectNote(note: NoteEntry) {
if ($activeNotePath === note.path) return; if ($activeNotePath === note.path) {
if (isMobile) $mobileView = 'editor';
return;
}
try { try {
const content = await readNote(note.path); const content = await readNote(note.path);
onBeforeNoteSwitch();
$activeNote = content; $activeNote = content;
$activeNotePath = note.path; $activeNotePath = note.path;
$editorDirty = false; $editorDirty = false;
@@ -193,12 +365,21 @@
} }
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'daily') {
const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
await handleDayClick(date);
calMonth = today.getMonth();
calYear = today.getFullYear();
return;
}
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null; const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
} }
@@ -271,6 +452,32 @@
} }
} }
async function handleRestoreNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await restoreNotebook(nb.path);
noteCache.clear();
await refresh();
} catch (e) {
console.error('Failed to restore notebook:', e);
} finally {
trashBusy = null;
}
}
async function handleDeleteNotebook(nb: TrashNotebookEntry) {
trashBusy = nb.path;
try {
await permanentDelete(nb.path);
noteCache.clear();
trashNotebooks = trashNotebooks.filter(n => n.path !== nb.path);
} catch (e) {
console.error('Failed to delete notebook permanently:', e);
} finally {
trashBusy = null;
}
}
async function handleTogglePin(note: NoteEntry) { async function handleTogglePin(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -321,7 +528,7 @@
if ($activeNotePath === note.path && $activeNote) { if ($activeNotePath === note.path && $activeNote) {
$activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: newTags } }; $activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: newTags } };
} }
$tags = await getAllTags(); await refreshTags();
} catch (e) { } catch (e) {
console.error('Failed to save tags:', e); console.error('Failed to save tags:', e);
} }
@@ -357,7 +564,7 @@
} }
} }
} }
$tags = await getAllTags(); await refreshTags();
} catch (e) { } catch (e) {
console.error('Failed to add tag to batch:', e); console.error('Failed to add tag to batch:', e);
} }
@@ -379,7 +586,7 @@
} }
} }
} }
$tags = await getAllTags(); await refreshTags();
} catch (e) { } catch (e) {
console.error('Failed to remove tag from batch:', e); console.error('Failed to remove tag from batch:', e);
} }
@@ -446,9 +653,18 @@
} }
} }
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
return { x, y };
}
function onContextMenu(e: MouseEvent, note: NoteEntry) { function onContextMenu(e: MouseEvent, note: NoteEntry) {
e.preventDefault(); e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, note }; const { x, y } = clampMenu(e.clientX, e.clientY);
contextMenu = { x, y, note };
} }
function startRename(note: NoteEntry) { function startRename(note: NoteEntry) {
@@ -464,6 +680,23 @@
} }
function handleNoteClick(e: MouseEvent, note: NoteEntry) { function handleNoteClick(e: MouseEvent, note: NoteEntry) {
// Mobile: if long-press just fired, ignore the click
if (isMobile && longPressTriggered) {
longPressTriggered = false;
e.preventDefault();
return;
}
// Mobile: if in selection mode (or Android multi-select mode), tap toggles selection
if (isMobile && (selectedPaths.size > 0 || multiSelectMode)) {
const next = new Set(selectedPaths);
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
return;
}
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Toggle individual selection // Toggle individual selection
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
@@ -559,7 +792,11 @@
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
sortMenu = { x: rect.left, y: rect.bottom + 4 }; const menuWidth = isMobile ? 220 : 180;
let x = rect.left;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (x < 4) x = 4;
sortMenu = { x, y: rect.bottom + 4 };
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
@@ -586,14 +823,21 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1" /><rect x="3" y="14" width="7" height="7" rx="1" />{#if multiSelectMode}<polyline points="6 6.5 7 7.5 9 5.5" stroke-width="2" /><polyline points="6 17.5 7 18.5 9 16.5" stroke-width="2" />{/if}<line x1="14" y1="6.5" x2="21" y2="6.5" /><line x1="14" y1="17.5" x2="21" y2="17.5" />
</svg>
</button>
{/if}
<button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}"> <button class="icon-btn" onclick={openSortMenu} title="Sort: {$sortMode}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" /> <line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
</svg> </svg>
</button> </button>
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class="icon-btn" onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
@@ -610,6 +854,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">
@@ -648,8 +893,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>
@@ -660,6 +983,37 @@
</div> </div>
{/if} {/if}
{#if $viewMode === 'trash' && trashNotebooks.length > 0}
{#each trashNotebooks as nb (nb.path)}
<div class="note-item trash-notebook" class:busy={trashBusy === nb.path}>
<div class="note-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
{nb.name}
</div>
{#if trashBusy === nb.path}
<div class="note-preview trash-busy">
<svg class="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
Working...
</div>
{:else}
<div class="note-preview">{nb.note_count} {nb.note_count === 1 ? 'note' : 'notes'}</div>
<div class="trash-notebook-actions">
<button class="trash-nb-btn restore" onclick={() => handleRestoreNotebook(nb)} title="Restore notebook">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 105.64-11.36L1 10"/></svg>
Restore
</button>
<button class="trash-nb-btn delete" onclick={() => handleDeleteNotebook(nb)} title="Delete permanently">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
Delete
</button>
</div>
{/if}
</div>
{/each}
{/if}
<div style="height: {topPad}px"></div> <div style="height: {topPad}px"></div>
{#each visibleNotes as note, i (note.path)} {#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i} {@const noteIndex = startIndex + i}
@@ -686,18 +1040,23 @@
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY);
// If multi-selected and right-clicking a selected note, show batch menu // If multi-selected and right-clicking a selected note, show batch menu
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) { if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
contextMenu = { x: e.clientX, y: e.clientY, note }; contextMenu = { x: pos.x, y: pos.y, note };
return; return;
} }
// Otherwise clear selection and show single menu // Otherwise clear selection and show single menu
if (selectedPaths.size > 0 && !selectedPaths.has(note.path)) { if (selectedPaths.size > 0 && !selectedPaths.has(note.path)) {
clearSelection(); clearSelection();
} }
contextMenu = { x: e.clientX, y: e.clientY, note }; contextMenu = { x: pos.x, y: pos.y, note };
}} }}
draggable="true" draggable="true"
ondragstart={(e) => { ondragstart={(e) => {
@@ -936,6 +1295,12 @@
Add to Quick Access Add to Quick Access
</button> </button>
{/if} {/if}
<button onclick={() => { const n = contextMenu!.note; contextMenu = null; openNoteWindow(n.path, n.meta.title); }}>
<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="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
Open in New Window
</button>
<button onclick={() => startRename(contextMenu!.note)}> <button onclick={() => startRename(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="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/> <path d="M17 3a2.85 2.85 0 114 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>
@@ -948,6 +1313,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"/>
@@ -1019,6 +1392,7 @@
.list-actions { .list-actions {
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
} }
@@ -1038,12 +1412,30 @@
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;
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-item { .note-item {
display: block; display: block;
width: 100%; width: 100%;
@@ -1519,6 +1911,10 @@
padding: 12px 16px; padding: 12px 16px;
} }
.note-list.mobile .list-actions {
gap: 10px;
}
.note-list.mobile .list-title { .note-list.mobile .list-title {
font-size: 16px; font-size: 16px;
} }
@@ -1530,7 +1926,7 @@
} }
.note-list.mobile .list-content { .note-list.mobile .list-content {
padding: 4px 8px; padding: 4px 8px 180px;
} }
.note-list.mobile .note-item { .note-list.mobile .note-item {
@@ -1583,6 +1979,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 {
@@ -1593,6 +1992,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 {
@@ -1637,4 +2037,204 @@
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
} }
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.mobile-create-btn:active {
opacity: 0.8;
}
.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>
+327
View File
@@ -0,0 +1,327 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import Editor from './Editor.svelte';
import {
activeNote,
activeNotePath,
editorDirty,
readOnly,
sourceMode,
theme
} from '$lib/stores/app';
import { readNote } from '$lib/api';
import type { FileEvent } from '$lib/types';
let { notePath }: { notePath: string } = $props();
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
let editor = $state<Editor>(null!);
let unlistenFileChange: (() => void) | null = null;
let maximized = $state(false);
let loadError = $state<string | null>(null);
async function checkMaximized() {
maximized = await appWindow.isMaximized();
}
$effect(() => {
checkMaximized();
const unlisten = appWindow.onResized(() => checkMaximized());
return () => { unlisten.then(fn => fn()); };
});
$effect(() => {
const themeValue = $theme || 'system';
const root = document.documentElement;
root.classList.remove('dark');
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
});
let lastMouseDown = 0;
const RESIZE_EDGE = 6;
function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
}
const now = Date.now();
if (now - lastMouseDown < 300) {
appWindow.toggleMaximize();
lastMouseDown = 0;
return;
}
lastMouseDown = now;
appWindow.startDragging();
}
function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey;
if (mod && !e.shiftKey && e.code === 'KeyS') {
e.preventDefault();
editor?.forceSave();
return;
}
if (mod && e.shiftKey && e.code === 'KeyM') {
e.preventDefault();
$sourceMode = !$sourceMode;
}
}
onMount(async () => {
try {
const content = await readNote(notePath);
$activeNote = content;
$activeNotePath = notePath;
$editorDirty = false;
setTimeout(() => {
editor?.loadNote(notePath, content.content);
appWindow.setTitle(`${content.meta.title} - HelixNotes`);
}, 50);
} catch (e) {
loadError = String(e);
}
unlistenFileChange = await listen<FileEvent>('file-changed', async (event) => {
if (event.payload.path === notePath && event.payload.event_type === 'modify') {
if (!$editorDirty) {
try {
const content = await readNote(notePath);
$activeNote = content;
editor?.loadNote(notePath, content.content);
} catch (_) {}
}
}
});
});
onDestroy(() => {
unlistenFileChange?.();
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="note-window">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="nw-titlebar" class:macos={isMac} onmousedown={handleMouseDown}>
<div class="nw-titlebar-brand">
<svg width="14" height="14" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" />
<circle cx="16" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="16" r="3.5" fill="white" opacity="0.9" />
<circle cx="16" cy="32" r="3.5" fill="white" opacity="0.9" />
<circle cx="32" cy="32" r="3.5" fill="white" opacity="0.9" />
<line x1="19" y1="18" x2="29" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
<line x1="29" y1="18" x2="19" y2="30" stroke="white" stroke-width="2" stroke-linecap="round" opacity="0.7" />
</svg>
<span class="nw-title">{$activeNote?.meta.title || 'Loading...'}</span>
{#if $editorDirty}
<span class="nw-dirty-dot" title="Unsaved changes"></span>
{/if}
</div>
<div class="titlebar-actions">
<button class="nw-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title="Toggle Source Mode">
<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="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</button>
<button class="nw-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Switch to Edit Mode' : 'Switch to View Mode'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if $readOnly}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
{:else}
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
{/if}
</svg>
</button>
</div>
{#if !isMac}
<div class="titlebar-controls">
<button class="titlebar-btn" onclick={() => appWindow.minimize()} title="Minimize">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="5" x2="9" y2="5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<button class="titlebar-btn" onclick={() => appWindow.toggleMaximize()} title={maximized ? 'Restore' : 'Maximize'}>
{#if maximized}
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="2.5" y="0.5" width="7" height="7" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
<rect x="0.5" y="2.5" width="7" height="7" rx="1" fill="var(--bg-secondary)" stroke="currentColor" stroke-width="1.2" />
</svg>
{:else}
<svg width="10" height="10" viewBox="0 0 10 10">
<rect x="1" y="1" width="8" height="8" rx="1" fill="none" stroke="currentColor" stroke-width="1.2" />
</svg>
{/if}
</button>
<button class="titlebar-btn titlebar-close" onclick={() => appWindow.close()} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
</div>
{#if loadError}
<div class="nw-error">
<p>Failed to load note</p>
<p class="nw-error-detail">{loadError}</p>
</div>
{:else}
<div class="nw-editor">
<Editor bind:this={editor} />
</div>
{/if}
</div>
<style>
.note-window {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--bg-primary);
}
.nw-titlebar {
display: flex;
align-items: center;
height: 34px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
user-select: none;
flex-shrink: 0;
-webkit-app-region: drag;
}
.nw-titlebar.macos {
padding-left: 78px;
}
.nw-titlebar-brand {
display: flex;
align-items: center;
gap: 8px;
padding-left: 14px;
pointer-events: none;
flex: 1;
min-width: 0;
}
.nw-title {
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nw-dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
}
.titlebar-actions {
display: flex;
align-items: center;
gap: 4px;
margin-right: 8px;
-webkit-app-region: no-drag;
}
.nw-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 7px;
background: var(--bg-hover);
color: var(--text-secondary);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.nw-btn:hover {
background: var(--bg-tertiary, var(--bg-hover));
color: var(--text-primary);
}
.nw-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.titlebar-controls {
display: flex;
height: 100%;
-webkit-app-region: no-drag;
}
.titlebar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 100%;
border: none;
background: none;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.titlebar-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.titlebar-close:hover {
background: #e81123;
color: white;
}
.nw-editor {
flex: 1;
overflow: hidden;
}
.nw-error {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
.nw-error-detail {
font-size: 12px;
color: var(--text-tertiary);
max-width: 400px;
text-align: center;
word-break: break-word;
}
</style>
+5 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig } from '$lib/stores/app'; import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app';
import { searchNotes, readNote } from '$lib/api'; import { searchNotes, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import type { SearchResult } from '$lib/types'; import type { SearchResult } from '$lib/types';
@@ -83,6 +83,8 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
const isMobile = /android|ios/i.test(navigator.userAgent);
async function openResult(result: SearchResult) { async function openResult(result: SearchResult) {
try { try {
const content = await readNote(result.path); const content = await readNote(result.path);
@@ -90,6 +92,7 @@
$activeNotePath = result.path; $activeNotePath = result.path;
$editorDirty = false; $editorDirty = false;
$showSearch = false; $showSearch = false;
if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
console.error('Failed to open search result:', e); console.error('Failed to open search result:', e);
} }
@@ -118,7 +121,7 @@
oninput={handleInput} oninput={handleInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
/> />
<kbd class="search-esc">Esc</kbd> {#if !isMobile}<kbd class="search-esc">Esc</kbd>{/if}
</div> </div>
{#if results.length > 0} {#if results.length > 0}
+245 -10
View File
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab } from '$lib/stores/app'; import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent);
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates'; type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates';
let activeTab = $state<Tab>('styling'); let activeTab = $state<Tab>('styling');
@@ -54,6 +56,17 @@
updateMessage = null; updateMessage = null;
updateAvailable = null; updateAvailable = null;
try { try {
if (isMobile) {
await checkForUpdateMobile();
const global = $globalUpdateAvailable;
if (global) {
updateAvailable = { version: global.version, body: global.body };
updateMessage = { type: 'info', text: `Version ${global.version} is available!` };
} else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
}
} else {
const { check: checkUpdate } = await import('@tauri-apps/plugin-updater');
const update = await checkUpdate(); const update = await checkUpdate();
if (update) { if (update) {
updateObj = update; updateObj = update;
@@ -64,6 +77,7 @@
} else { } else {
updateMessage = { type: 'success', text: 'You are on the latest version.' }; updateMessage = { type: 'success', text: 'You are on the latest version.' };
} }
}
} catch (e) { } catch (e) {
updateMessage = { type: 'error', text: `Failed to check: ${e}` }; updateMessage = { type: 'error', text: `Failed to check: ${e}` };
} finally { } finally {
@@ -204,18 +218,20 @@
let aiProvider = $state<string | null>($appConfig?.ai_provider ?? null); let aiProvider = $state<string | null>($appConfig?.ai_provider ?? null);
let aiApiKey = $derived.by(() => { let aiApiKey = $derived.by(() => {
if (aiProvider === 'openai') return _openaiKey; if (aiProvider === 'openai') return _openaiKey;
if (aiProvider === 'ollama') return '';
return _anthropicKey; return _anthropicKey;
}); });
let _anthropicKey = $state($appConfig?.ai_api_key ?? ''); let _anthropicKey = $state($appConfig?.ai_api_key ?? '');
let _openaiKey = $state($appConfig?.openai_api_key ?? ''); let _openaiKey = $state($appConfig?.openai_api_key ?? '');
let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-5-20250929'); let _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434');
let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-6');
let aiWritingStyle = $state($appConfig?.ai_writing_style ?? ''); let aiWritingStyle = $state($appConfig?.ai_writing_style ?? '');
let aiShowKey = $state(false); let aiShowKey = $state(false);
let aiTestLoading = $state(false); let aiTestLoading = $state(false);
let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
const anthropicModels = [ const anthropicModels = [
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', desc: 'Balanced' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', desc: 'Balanced' },
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6', desc: 'Most capable' }, { value: 'claude-opus-4-6', label: 'Claude Opus 4.6', desc: 'Most capable' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', desc: 'Fast' }, { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', desc: 'Fast' },
]; ];
@@ -228,13 +244,15 @@
let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels); let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels);
async function saveAiSettings() { async function saveAiSettings() {
await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || null); const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null;
await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || null, baseUrl);
if ($appConfig) { if ($appConfig) {
$appConfig = { $appConfig = {
...$appConfig, ...$appConfig,
ai_provider: aiProvider, ai_provider: aiProvider,
ai_api_key: _anthropicKey || null, ai_api_key: _anthropicKey || null,
openai_api_key: _openaiKey || null, openai_api_key: _openaiKey || null,
ollama_base_url: _ollamaBaseUrl || null,
ai_model: aiModel, ai_model: aiModel,
ai_writing_style: aiWritingStyle || null, ai_writing_style: aiWritingStyle || null,
}; };
@@ -293,9 +311,17 @@
{ name: 'Mono', value: 'mono', stack: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace' }, { name: 'Mono', value: 'mono', stack: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace' },
]; ];
const lineHeightPresets = [
{ label: 'Tight', value: 1.4 },
{ label: 'Default', value: 1.6 },
{ label: 'Relaxed', value: 1.8 },
{ label: 'Loose', value: 2.0 },
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo'); let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let activeFontSize = $state($appConfig?.font_size ?? 14); let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system'); let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
// General settings // General settings
let compactNotes = $state($appConfig?.compact_notes ?? false); let compactNotes = $state($appConfig?.compact_notes ?? false);
@@ -422,6 +448,17 @@
document.documentElement.style.setProperty('--editor-font-family', stack); document.documentElement.style.setProperty('--editor-font-family', stack);
} }
function selectLineHeight(value: number) {
activeLineHeight = value;
applyLineHeight(value);
if ($appConfig) $appConfig.line_height = value;
setLineHeight(value).catch((e) => console.error('Failed to save line height:', e));
}
function applyLineHeight(value: number) {
document.documentElement.style.setProperty('--editor-line-height', String(value));
}
function close() { function close() {
// Ensure AI settings are saved when closing (in case input wasn't blurred) // Ensure AI settings are saved when closing (in case input wasn't blurred)
// Only save if the key differs from what's already stored // Only save if the key differs from what's already stored
@@ -454,6 +491,11 @@
applyFontFamily(preset.stack); applyFontFamily(preset.stack);
} }
} }
const savedLineHeight = $appConfig?.line_height;
if (savedLineHeight) {
activeLineHeight = savedLineHeight;
applyLineHeight(savedLineHeight);
}
// Sync general settings // Sync general settings
if ($appConfig) { if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false; compactNotes = $appConfig.compact_notes ?? false;
@@ -478,9 +520,9 @@
{#if $showSettings} {#if $showSettings}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-overlay" onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}> <div class="settings-overlay" class:mobile={isMobile} onclick={close} onkeydown={(e) => { if (e.key === 'Escape') close(); }}>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="settings-panel" onclick={(e) => e.stopPropagation()}> <div class="settings-panel" class:mobile={isMobile} onclick={(e) => e.stopPropagation()}>
<div class="settings-header"> <div class="settings-header">
<h2>Settings</h2> <h2>Settings</h2>
<button class="close-btn" onclick={close}> <button class="close-btn" onclick={close}>
@@ -511,18 +553,22 @@
</svg> </svg>
Styling Styling
</button> </button>
{#if !isMobile}
<button class="tab-btn" class:active={activeTab === 'import'} onclick={() => activeTab = 'import'}> <button class="tab-btn" class:active={activeTab === 'import'} onclick={() => activeTab = 'import'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M8 5H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V7a2 2 0 00-2-2h-4"/> <path d="M12 3v12"/><path d="m8 11 4 4 4-4"/><path d="M8 5H4a2 2 0 00-2 2v10a2 2 0 002 2h16a2 2 0 002-2V7a2 2 0 00-2-2h-4"/>
</svg> </svg>
Import Import
</button> </button>
{/if}
{#if !isMobile}
<button class="tab-btn" class:active={activeTab === 'backup'} onclick={() => { activeTab = 'backup'; loadBackups(); }}> <button class="tab-btn" class:active={activeTab === 'backup'} onclick={() => { activeTab = 'backup'; loadBackups(); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 009 9 9.75 9.75 0 006.74-2.74L21 16"/><path d="M16 16h5v5"/> <path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 009 9 9.75 9.75 0 006.74-2.74L21 16"/><path d="M16 16h5v5"/>
</svg> </svg>
Backup Backup
</button> </button>
{/if}
<button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}> <button class="tab-btn" class:active={activeTab === 'ai'} onclick={() => activeTab = 'ai'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/> <path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
@@ -562,6 +608,20 @@
</div> </div>
</div> </div>
{#if isMobile}
<div class="settings-section">
<h3>Vault</h3>
<p class="setting-desc" style="margin-bottom: 12px; color: var(--text-tertiary); font-size: 13px;">Current: <strong style="color: var(--text-primary);">{$appConfig?.active_vault?.split('/').pop() ?? 'Unknown'}</strong></p>
<button class="import-btn" onclick={() => { $showSettings = false; $vaultReady = false; }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
Switch Vault
</button>
</div>
{/if}
{#if !isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>Performance</h3> <h3>Performance</h3>
<label class="setting-toggle"> <label class="setting-toggle">
@@ -607,6 +667,7 @@
</label> </label>
{/if} {/if}
</div> </div>
{/if}
</div> </div>
{:else if activeTab === 'editor'} {:else if activeTab === 'editor'}
<div class="tab-content"> <div class="tab-content">
@@ -621,6 +682,7 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
{#if !isMobile}
<label class="setting-toggle" style="margin-top: 12px;"> <label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Show line numbers</span> <span class="setting-name">Show line numbers</span>
@@ -630,6 +692,7 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
{/if}
<label class="setting-toggle" style="margin-top: 12px;"> <label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Open notes in View Mode</span> <span class="setting-name">Open notes in View Mode</span>
@@ -654,6 +717,7 @@
</label> </label>
</div> </div>
{#if !isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>PDF Preview</h3> <h3>PDF Preview</h3>
<label class="setting-toggle"> <label class="setting-toggle">
@@ -685,6 +749,7 @@
<span class="setting-hint">Default height for PDF previews in notes</span> <span class="setting-hint">Default height for PDF previews in notes</span>
</div> </div>
{/if} {/if}
{/if}
</div> </div>
{:else if activeTab === 'styling'} {:else if activeTab === 'styling'}
<div class="tab-content"> <div class="tab-content">
@@ -748,6 +813,22 @@
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Line Height</h3>
<div class="font-size-options">
{#each lineHeightPresets as preset}
<button
class="font-size-btn"
class:active={activeLineHeight === preset.value}
onclick={() => selectLineHeight(preset.value)}
>
<span class="font-size-label">{preset.label}</span>
<span class="font-size-value">{preset.value}</span>
</button>
{/each}
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>Font</h3> <h3>Font</h3>
<div class="font-family-options"> <div class="font-family-options">
@@ -805,6 +886,7 @@
</label> </label>
<div class="backup-actions"> <div class="backup-actions">
{#if !isMobile}
<button class="backup-link-btn" onclick={handleSelectBackupFolder}> <button class="backup-link-btn" onclick={handleSelectBackupFolder}>
<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">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/> <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
@@ -814,6 +896,7 @@
{#if $appConfig?.backup_location} {#if $appConfig?.backup_location}
<p class="backup-path">{$appConfig.backup_location}</p> <p class="backup-path">{$appConfig.backup_location}</p>
{/if} {/if}
{/if}
<button class="backup-link-btn" onclick={handleBackupNow} disabled={backupLoading}> <button class="backup-link-btn" onclick={handleBackupNow} disabled={backupLoading}>
{#if backupLoading} {#if backupLoading}
<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg> <svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
@@ -941,12 +1024,33 @@
<h3>Provider</h3> <h3>Provider</h3>
<div class="setting-options"> <div class="setting-options">
<button class="option-btn" class:active={!aiProvider} onclick={() => { if (!aiProvider) return; aiProvider = null; aiTestMessage = null; saveAiSettings(); }}>Disabled</button> <button class="option-btn" class:active={!aiProvider} onclick={() => { if (!aiProvider) return; aiProvider = null; aiTestMessage = null; saveAiSettings(); }}>Disabled</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-5-20250929'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button> <button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button> <button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
</div> </div>
</div> </div>
{#if aiProvider} {#if aiProvider}
{#if aiProvider === 'ollama'}
<p class="setting-hint" style="color: var(--text-success, #4ade80); margin-top: -4px; margin-bottom: 12px;">Your data stays on your device. No text is sent to any external server.</p>
{:else}
<p class="setting-hint" style="color: var(--text-warning, #f59e0b); margin-top: -4px; margin-bottom: 12px;">Your selected text and note content will be sent to {aiProvider === 'openai' ? 'OpenAI' : 'Anthropic'} servers for processing.</p>
{/if}
{#if aiProvider === 'ollama'}
<div class="settings-section">
<h3>Server URL</h3>
<input
type="text"
class="ai-key-input"
placeholder="http://localhost:11434"
value={_ollamaBaseUrl}
oninput={(e) => { _ollamaBaseUrl = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p>
</div>
{:else}
<div class="settings-section"> <div class="settings-section">
<h3>API Key</h3> <h3>API Key</h3>
<div class="ai-key-row"> <div class="ai-key-row">
@@ -972,9 +1076,21 @@
<p class="setting-hint">Get your API key from <a href="https://console.anthropic.com/settings/keys" target="_blank" class="ai-link">console.anthropic.com</a></p> <p class="setting-hint">Get your API key from <a href="https://console.anthropic.com/settings/keys" target="_blank" class="ai-link">console.anthropic.com</a></p>
{/if} {/if}
</div> </div>
{/if}
<div class="settings-section"> <div class="settings-section">
<h3>Model</h3> <h3>Model</h3>
{#if aiProvider === 'ollama'}
<input
type="text"
class="ai-key-input"
placeholder="gemma3:4b"
value={aiModel}
oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<p class="setting-hint">Enter the model name as shown by <code>ollama list</code></p>
{:else}
<div class="ai-model-options"> <div class="ai-model-options">
{#each aiModels as m} {#each aiModels as m}
<button <button
@@ -987,6 +1103,7 @@
</button> </button>
{/each} {/each}
</div> </div>
{/if}
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -1004,7 +1121,7 @@
<div class="settings-section"> <div class="settings-section">
<h3>Connection</h3> <h3>Connection</h3>
<button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || !aiApiKey}> <button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || (aiProvider !== 'ollama' && !aiApiKey)}>
{#if aiTestLoading} {#if aiTestLoading}
<svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg> <svg class="spinner-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>
Testing... Testing...
@@ -1027,7 +1144,11 @@
<div class="settings-section"> <div class="settings-section">
<h3>Usage</h3> <h3>Usage</h3>
{#if isMobile}
<p class="import-desc">Use the AI button in the toolbar or header to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
{:else}
<p class="import-desc">Select text in the editor and right-click to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p> <p class="import-desc">Select text in the editor and right-click to access AI writing tools: improve, fix grammar, rewrite, summarize, translate, and more.</p>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -1093,6 +1214,13 @@
<p>Update via your AUR helper:</p> <p>Update via your AUR helper:</p>
<code>yay -Syu helixnotes</code> <code>yay -Syu helixnotes</code>
</div> </div>
{:else if $installType === 'android'}
<button class="update-install-btn" onclick={() => { openUrl('https://helixnotes.com/#download').catch(() => {}); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Download from Website
</button>
{:else} {:else}
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener"> <a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -2007,4 +2135,111 @@
color: var(--accent); color: var(--accent);
} }
/* ═══ Mobile ═══ */
.settings-overlay.mobile {
align-items: stretch;
justify-content: stretch;
}
.settings-panel.mobile {
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
border: none;
}
.settings-panel.mobile .settings-content {
flex-direction: column;
}
.settings-panel.mobile .settings-tabs {
flex-direction: row;
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--border-light);
padding: 8px 8px 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
gap: 0;
}
.settings-panel.mobile .settings-tabs::-webkit-scrollbar {
display: none;
}
.settings-panel.mobile .tab-btn {
flex-shrink: 0;
flex-direction: column;
gap: 4px;
padding: 8px 14px 10px;
font-size: 11px;
border-radius: 8px 8px 0 0;
}
.settings-panel.mobile .tab-btn.active {
border-bottom: 2px solid var(--accent);
border-radius: 8px 8px 0 0;
}
.settings-panel.mobile .settings-header {
padding-top: calc(env(safe-area-inset-top, 12px) + 12px);
}
.settings-panel.mobile .settings-body {
padding: 16px;
}
.settings-panel.mobile .setting-name {
font-size: 14px;
}
.settings-panel.mobile .setting-desc {
font-size: 12px;
}
.settings-panel.mobile .toggle-switch {
width: 44px;
height: 26px;
}
.settings-panel.mobile .toggle-knob {
width: 22px;
height: 22px;
}
.settings-panel.mobile .toggle-switch.on .toggle-knob {
transform: translateX(18px);
}
.settings-panel.mobile .option-btn {
padding: 12px 10px;
font-size: 14px;
}
.settings-panel.mobile .theme-btn {
padding: 12px 10px;
font-size: 14px;
}
.settings-panel.mobile .accent-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.settings-panel.mobile .accent-swatch {
padding: 14px 8px;
}
.settings-panel.mobile .font-size-options {
gap: 8px;
}
.settings-panel.mobile .font-size-btn {
padding: 12px 6px;
}
</style> </style>
+117 -6
View File
@@ -15,9 +15,10 @@
notebookIcons, notebookIcons,
appConfig, appConfig,
quickAccessPaths, quickAccessPaths,
collapsedNotebooks collapsedNotebooks,
rootNoteCount
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
@@ -49,13 +50,30 @@
} }
} }
export async function refresh() { export async function refresh() {
try { try {
$notebooks = await getNotebooks(); if (isMobile) {
// On mobile, parallelize and skip getAllTags (derive from $notes instead)
const [nbs, icons, qaNotes, rootCount] = await Promise.all([
getNotebooks(),
getNotebookIcons(),
getQuickAccess(),
countRootNotes(),
]);
$notebooks = nbs;
$notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path);
$rootNoteCount = rootCount;
} else {
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();
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
}
} catch (e) { } catch (e) {
console.error('Failed to refresh sidebar:', e); console.error('Failed to refresh sidebar:', e);
} }
@@ -68,6 +86,19 @@
onViewChanged(); onViewChanged();
} }
function selectUnfiled() {
const vault = $appConfig?.active_vault;
if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
selectAllNotes();
return;
}
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
onViewChanged();
}
function selectNotebook(nb: NotebookEntry) { function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook // Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
@@ -94,6 +125,13 @@
onViewChanged(); onViewChanged();
} }
function selectDaily() {
$viewMode = 'daily';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -271,9 +309,18 @@
return convertFileSrc(`${vaultRoot}/${iconPath}`); return convertFileSrc(`${vaultRoot}/${iconPath}`);
} }
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 300): { x: number; y: number } {
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
if (x < 4) x = 4;
if (y < 4) y = 4;
return { x, y };
}
function onContextMenu(e: MouseEvent, nb: NotebookEntry) { function onContextMenu(e: MouseEvent, nb: NotebookEntry) {
e.preventDefault(); e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY, notebook: nb }; const { x, y } = clampMenu(e.clientX, e.clientY);
contextMenu = { x, y, notebook: nb };
} }
function closeContextMenu() { function closeContextMenu() {
@@ -283,7 +330,8 @@
function onTrashContextMenu(e: MouseEvent) { function onTrashContextMenu(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
trashContextMenu = { x: e.clientX, y: e.clientY }; const { x, y } = clampMenu(e.clientX, e.clientY, 180, 60);
trashContextMenu = { x, y };
} }
async function handleEmptyTrash() { async function handleEmptyTrash() {
@@ -310,6 +358,7 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}> <aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
{#if !isMobile}
<div class="sidebar-header"> <div class="sidebar-header">
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar"> <button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -329,6 +378,7 @@
</button> </button>
{/if} {/if}
</div> </div>
{/if}
{#if !$sidebarCollapsed} {#if !$sidebarCollapsed}
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -355,6 +405,20 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<button
class="nav-item"
class:active={$viewMode === 'daily'}
onclick={selectDaily}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily Notes</span>
</button>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'trash'} class:active={$viewMode === 'trash'}
@@ -421,13 +485,50 @@
{/if} {/if}
<div class="notebook-list"> <div class="notebook-list">
{#if $rootNoteCount > 0}
<button
class="notebook-item"
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled}
>
<span style="width:13px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button>
{/if}
{#each $notebooks as nb (nb.path)} {#each $notebooks as nb (nb.path)}
{@render notebookItem(nb, 0)} {@render notebookItem(nb, 0)}
{/each} {/each}
</div> </div>
{#if isMobile && $tags.length > 0}
<div class="tags-section-inline">
<button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}>
<span class="section-title">Tags</span>
<span class="tag-count" style="margin-left: auto">{$tags.length}</span>
</button>
{#if !tagsCollapsed}
<div class="tag-list">
{#each $tags as [tag, count]}
<button
class="tag-item"
class:active={$viewMode === 'tag' && $activeTag === tag}
onclick={() => selectTag(tag)}
>
<span class="tag-hash">#</span>
<span class="tag-name">{tag}</span>
<span class="tag-count">{count}</span>
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div> </div>
{#if $tags.length > 0} {#if !isMobile && $tags.length > 0}
<div class="tags-section"> <div class="tags-section">
<button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}> <button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}>
<span class="section-title">Tags</span> <span class="section-title">Tags</span>
@@ -1052,6 +1153,16 @@
height: 20px; height: 20px;
} }
.sidebar.mobile .section {
padding-bottom: 180px;
}
.sidebar.mobile .tags-section-inline {
border-top: 1px solid var(--border-light);
padding: 4px 0;
margin-top: 8px;
}
.sidebar.mobile .section-header { .sidebar.mobile .section-header {
padding: 12px 16px 6px; padding: 12px 16px 6px;
min-height: 44px; min-height: 44px;
+1
View File
@@ -221,6 +221,7 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active { .switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent); color: var(--accent);
+238 -7
View File
@@ -11,12 +11,56 @@
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []); let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let vaultName = $state('HelixNotes');
let hasPermission = $state(!isMobile);
let selectedLocation = $state('Documents');
let customPath = $state('');
const storageLocations = [
{ label: 'Documents', path: '/storage/emulated/0/Documents' },
{ label: 'Downloads', path: '/storage/emulated/0/Download' },
{ label: 'Internal Storage', path: '/storage/emulated/0' },
];
if (isMobile) {
checkPermission();
setTimeout(checkPermission, 500);
(window as any).__storagePermissionGranted = () => { hasPermission = true; };
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300);
});
}
function checkPermission() {
try {
const bridge = (window as any).Android;
if (bridge?.hasStoragePermission) {
hasPermission = bridge.hasStoragePermission() === true;
}
} catch { /* bridge not ready */ }
}
function requestPermission() {
try {
const bridge = (window as any).Android;
bridge?.requestStoragePermission?.();
} catch {
error = 'Go to Settings > Apps > HelixNotes > Permissions and enable "All files access".';
}
}
function getMobileBasePath(): string {
if (selectedLocation === 'Custom') {
return customPath.trim() || '/storage/emulated/0/Documents';
}
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents';
}
let fullPath = $derived(isMobile ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}` : '');
async function pickFolder() { async function pickFolder() {
if (isMobile) { if (isMobile) {
// Use shared Documents folder for Syncthing/Nextcloud compatibility await openSelectedVault(fullPath);
const vaultPath = '/storage/emulated/0/Documents/HelixNotes';
await openSelectedVault(vaultPath);
} else { } else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' }); const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
if (selected) { if (selected) {
@@ -29,6 +73,12 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
if (isMobile) {
try {
const bridge = (window as any).Android;
bridge?.prepareVaultDir?.(path);
} catch { /* Rust will retry mkdir itself */ }
}
await openVault(path); await openVault(path);
const config = await getAppConfig(); const config = await getAppConfig();
$appConfig = config; $appConfig = config;
@@ -68,7 +118,41 @@
<h1>HelixNotes</h1> <h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p> <p class="subtitle">Local markdown notes</p>
{#if isMobile} {#if isMobile}
<p class="description">Notes are stored in Documents/HelixNotes as Markdown files. Sync with Nextcloud, Syncthing, or any file sync app.</p> <p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{#if !hasPermission}
<button class="btn-permission" onclick={requestPermission}>
Grant File Access
</button>
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
{/if}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-options">
{#each storageLocations as loc}
<button
class="location-option"
class:active={selectedLocation === loc.label}
onclick={() => selectedLocation = loc.label}
>{loc.label}</button>
{/each}
<button
class="location-option"
class:active={selectedLocation === 'Custom'}
onclick={() => selectedLocation = 'Custom'}
>Custom</button>
</div>
{#if selectedLocation === 'Custom'}
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
{/if}
</div>
<div class="vault-name-input">
<label for="vault-name">Vault name</label>
<div class="vault-name-preview"><input id="vault-name" type="text" bind:value={vaultName} placeholder="HelixNotes" /></div>
<span class="vault-path-hint">{fullPath}</span>
</div>
{:else} {:else}
<p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder — existing .md files will be recognized automatically.</p> <p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder — existing .md files will be recognized automatically.</p>
{/if} {/if}
@@ -87,13 +171,13 @@
{/if} {/if}
</button> </button>
{#if !isMobile && $appConfig?.active_vault} {#if $appConfig?.active_vault}
<button class="btn-back" onclick={() => ($vaultReady = true)}> <button class="btn-back" onclick={() => ($vaultReady = true)}>
Back Back
</button> </button>
{/if} {/if}
{#if !isMobile && recentVaults.length > 0} {#if recentVaults.length > 0}
<div class="recent"> <div class="recent">
<span class="recent-label">Recent</span> <span class="recent-label">Recent</span>
{#each recentVaults as vault} {#each recentVaults as vault}
@@ -110,11 +194,13 @@
<style> <style>
.vault-picker { .vault-picker {
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
height: 100dvh;
background: var(--bg-primary); background: var(--bg-primary);
position: relative; position: relative;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
} }
.window-controls { .window-controls {
@@ -148,6 +234,9 @@
max-width: 400px; max-width: 400px;
width: 100%; width: 100%;
padding: 48px 32px; padding: 48px 32px;
padding-bottom: calc(120px + env(safe-area-inset-bottom, 0px));
margin: auto;
flex-shrink: 0;
} }
.logo { .logo {
@@ -276,6 +365,130 @@
white-space: nowrap; white-space: nowrap;
} }
.vault-name-input {
margin-bottom: 20px;
text-align: left;
}
.vault-name-input label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.vault-name-preview {
display: flex;
align-items: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 10px 12px;
font-size: 14px;
color: var(--text-tertiary);
}
.vault-name-preview input {
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
width: 100%;
padding: 0;
}
.location-selector {
margin-bottom: 16px;
text-align: left;
}
.location-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.location-options {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.location-option {
padding: 8px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.location-option.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.custom-path-input {
width: 100%;
margin-top: 8px;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
}
.custom-path-input:focus {
border-color: var(--accent);
}
.vault-path-hint {
display: block;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 6px;
opacity: 0.7;
word-break: break-all;
}
.btn-permission {
width: 100%;
padding: 12px 20px;
background: var(--accent);
color: white;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
margin-bottom: 6px;
transition: background 0.15s;
}
.btn-permission:hover {
background: var(--accent-hover);
}
.permission-hint {
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 20px;
opacity: 0.7;
}
/* Mobile */ /* Mobile */
.mobile .btn-primary { .mobile .btn-primary {
padding: 14px 20px; padding: 14px 20px;
@@ -295,4 +508,22 @@
width: 88px; width: 88px;
height: 88px; height: 88px;
} }
.mobile .btn-back {
padding: 14px 20px;
font-size: 16px;
border-radius: 12px;
}
.mobile .vault-item {
padding: 14px 16px;
}
.mobile .vault-name {
font-size: 15px;
}
.mobile .vault-path {
font-size: 13px;
}
</style> </style>
+63 -1
View File
@@ -1,4 +1,4 @@
import { writable, derived } from "svelte/store"; import { writable, derived, get } from "svelte/store";
import type { import type {
AppConfig, AppConfig,
NoteEntry, NoteEntry,
@@ -31,6 +31,7 @@ export const collapsedNotebooks = writable<string[]>([]);
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
export const rootNoteCount = writable<number>(0);
export const notes = writable<NoteEntry[]>([]); export const notes = writable<NoteEntry[]>([]);
export const tags = writable<[string, number][]>([]); export const tags = writable<[string, number][]>([]);
export const activeNote = writable<NoteContent | null>(null); export const activeNote = writable<NoteContent | null>(null);
@@ -59,6 +60,7 @@ export const updateAvailable = writable<{
} | null>(null); } | null>(null);
export const updateObj = writable<any>(null); export const updateObj = writable<any>(null);
export const installType = writable<string>("native"); export const installType = writable<string>("native");
export const androidApkUrl = writable<string | null>(null);
export async function checkForUpdate() { export async function checkForUpdate() {
try { try {
@@ -73,6 +75,66 @@ export async function checkForUpdate() {
} }
} }
function isNewerVersion(remote: string, local: string): boolean {
const r = remote.split(".").map(Number);
const l = local.split(".").map(Number);
for (let i = 0; i < Math.max(r.length, l.length); i++) {
const rv = r[i] || 0;
const lv = l[i] || 0;
if (rv > lv) return true;
if (rv < lv) return false;
}
return false;
}
export async function checkForUpdateMobile() {
try {
const { getVersion } = await import("@tauri-apps/api/app");
const currentVersion = await getVersion();
const res = await fetch("https://helixnotes.com/latest.json");
if (!res.ok) return;
const data = await res.json();
if (data.version && isNewerVersion(data.version, currentVersion)) {
updateAvailable.set({ version: data.version, body: data.notes });
const android = data.platforms?.["android-universal"];
if (android?.url) {
androidApkUrl.set(android.url);
}
}
} catch {
// Silent fail
}
}
// Note navigation history
interface NavHistoryState { stack: string[]; index: number; skipping: boolean; }
function createNavHistory() {
const store = writable<NavHistoryState>({ stack: [], index: -1, skipping: false });
return {
subscribe: store.subscribe,
push(path: string) {
store.update(s => {
if (s.skipping) return { ...s, skipping: false };
const trimmed = s.stack.slice(0, s.index + 1);
return { stack: [...trimmed, path], index: trimmed.length, skipping: false };
});
},
go(direction: -1 | 1): string | null {
let target: string | null = null;
store.update(s => {
const newIdx = s.index + direction;
if (newIdx < 0 || newIdx >= s.stack.length) return s;
target = s.stack[newIdx];
return { ...s, index: newIdx, skipping: true };
});
return target;
},
};
}
export const navHistory = createNavHistory();
export const canGoBack = derived(navHistory, $h => $h.index > 0);
export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length - 1);
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
+16 -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;
@@ -41,6 +53,7 @@ export interface AppConfig {
accent_color: string | null; accent_color: string | null;
font_size: number | null; font_size: number | null;
font_family: string | null; font_family: string | null;
line_height: number | null;
compact_notes: boolean; compact_notes: boolean;
time_format: string; time_format: string;
gpu_acceleration: boolean; gpu_acceleration: boolean;
@@ -59,6 +72,7 @@ export interface AppConfig {
ai_provider: string | null; ai_provider: string | null;
ai_api_key: string | null; ai_api_key: string | null;
openai_api_key: string | null; openai_api_key: string | null;
ollama_base_url: string | null;
ai_model: string; ai_model: string;
ai_writing_style: string | null; ai_writing_style: string | null;
default_view_mode: boolean; default_view_mode: boolean;
@@ -129,4 +143,5 @@ export type ViewMode =
| "tag" | "tag"
| "trash" | "trash"
| "search" | "search"
| "quickaccess"; | "quickaccess"
| "daily";
+42
View File
@@ -0,0 +1,42 @@
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
const openNoteWindows = new Map<string, string>();
export async function openNoteWindow(notePath: string, noteTitle: string) {
const existingLabel = openNoteWindows.get(notePath);
if (existingLabel) {
const existing = await WebviewWindow.getByLabel(existingLabel);
if (existing) {
await existing.show();
await existing.unminimize();
await existing.setFocus();
return;
}
openNoteWindows.delete(notePath);
}
const label = `note-${Date.now()}`;
const url = `/?note=${encodeURIComponent(notePath)}`;
const noteWindow = new WebviewWindow(label, {
url,
title: `${noteTitle} - HelixNotes`,
width: 900,
height: 700,
minWidth: 500,
minHeight: 400,
decorations: false,
center: true
});
openNoteWindows.set(notePath, label);
noteWindow.once('tauri://destroyed', () => {
openNoteWindows.delete(notePath);
});
noteWindow.once('tauri://error', (e) => {
console.error('Failed to create note window:', e);
openNoteWindows.delete(notePath);
});
}
+107 -23
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNotePath, installType, checkForUpdate } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { openFile, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
@@ -33,26 +32,20 @@
return resolved.join('/'); return resolved.join('/');
} }
// Detect install type and check for updates on startup (skip on mobile — updates via app store) const isAndroid = /android/i.test(navigator.userAgent);
onMount(() => {
if (!isMobile) { function openLocalFile(path: string) {
getInstallType().then(t => installType.set(t)).catch(() => {}); if (isAndroid) {
checkForUpdate(); 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));
}
} }
});
// Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => {
function handleLinkClick(e: MouseEvent) {
const target = (e.target as HTMLElement)?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
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));
} else if (!href.startsWith('#')) { } else if (!href.startsWith('#')) {
@@ -61,6 +54,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('/'));
@@ -69,12 +66,99 @@
absPath = normalizePath(`${vaultRoot}/${decoded}`); absPath = normalizePath(`${vaultRoot}/${decoded}`);
} }
} }
openFile(absPath).catch((err) => console.error('Failed to open file:', err)); }
// Internal .md note link — navigate within the app
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content });
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openLocalFile(absPath);
}
} }
} }
// Detect install type and check for updates on startup
onMount(() => {
if (isMobile) {
installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
}
});
// Intercept all link clicks in capture phase to prevent webview navigation
onMount(() => {
function handleLinkClick(e: MouseEvent) {
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;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
resolveAndHandleLink(href);
}
document.addEventListener('click', handleLinkClick, true); document.addEventListener('click', handleLinkClick, true);
return () => document.removeEventListener('click', handleLinkClick, true);
// On Android WebView, tapping a link can trigger native navigation before
// the JS click event fires (causing 404s for relative .md links).
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
// preventDefault to block the native navigation and handle it ourselves.
let touchStartY = 0;
function handleTouchStart(e: TouchEvent) {
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const touch = e.changedTouches[0];
if (!touch) return;
const endY = touch.clientY;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
// 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;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
resolveAndHandleLink(href);
}
if (isMobile) {
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', handleTouchEnd, true);
}
return () => {
document.removeEventListener('click', handleLinkClick, true);
if (isMobile) {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchend', handleTouchEnd, true);
}
};
}); });
</script> </script>
+17
View File
@@ -4,10 +4,19 @@
import { getAppConfig, openVault } from '$lib/api'; import { getAppConfig, openVault } from '$lib/api';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte';
let loading = $state(true); let loading = $state(true);
let noteWindowPath = $state<string | null>(null);
onMount(async () => { onMount(async () => {
// Check for note window mode
const params = new URLSearchParams(window.location.search);
const notePath = params.get('note');
if (notePath) {
noteWindowPath = notePath;
}
try { try {
const config = await getAppConfig(); const config = await getAppConfig();
$appConfig = config; $appConfig = config;
@@ -25,6 +34,9 @@
if (config.font_size) { if (config.font_size) {
document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`); document.documentElement.style.setProperty('--editor-font-size', `${config.font_size}px`);
} }
if (config.line_height) {
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
}
if (config.font_family) { if (config.font_family) {
const fontStacks: Record<string, string> = { const fontStacks: Record<string, string> = {
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
@@ -69,7 +81,10 @@
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
try { try {
// Note windows don't re-open the vault (already open in main process)
if (!noteWindowPath) {
await openVault(config.active_vault); await openVault(config.active_vault);
}
$vaultReady = true; $vaultReady = true;
} catch { } catch {
// Vault path might not exist anymore // Vault path might not exist anymore
@@ -86,6 +101,8 @@
<div class="loading"> <div class="loading">
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
{:else if noteWindowPath}
<NoteWindow notePath={noteWindowPath} />
{:else if $vaultReady} {:else if $vaultReady}
<AppLayout /> <AppLayout />
{:else} {:else}
+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
// //