Compare commits

...
40 Commits
Author SHA1 Message Date
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
Yuri Karamian 8f4f6f5f0e Scheduled backups, fixed settings/info panel sizing, added source mode shortcut, removed unused title mode setting 2026-02-21 22:46:52 +01:00
Yuri Karamian 74f1c7f6b0 v1.1.5 - Fix collapsible section collapsing when typing in content 2026-02-21 22:09:06 +01:00
Yuri Karamian 3b5cdf22eb v1.1.5 - Revert editor changes, fix is AppImage post-build pixbuf removal 2026-02-21 20:03:55 +01:00
Yuri Karamian 31f4867e91 Fix AppImage crash when inserting images by using Tauri native file dialog instead of GTK file chooser 2026-02-21 19:46:50 +01:00
Yuri Karamian 8baa976a02 v1.1.4 2026-02-19 22:59:37 +01:00
Yuri Karamian b4824973a5 v1.1.4 - Hide asset folders, notebook path in All Notes, nested notebook creation UX
- Hide asset folders (_res, _resources, _attachments, _assets, assets) from notebook listing
- Show notebook path in "All Notes" view
- Sub-notebook context menu + smart parent detection for nested notebook creation
2026-02-19 21:47:22 +01:00
Yuri Karamian a3229d451e v1.1.3 - Line numbers scroll fix, source mode undo/redo, Ctrl+K links, hard break fixes 2026-02-19 01:24:36 +01:00
Yuri Karamian c1faf0b0f5 v1.1.3 - Line numbers scroll fix, source mode undo/redo, Ctrl+K links, hard break fixes
- Line numbers scroll fix: source mode line numbers now scroll properly with content
- Source mode undo/redo: custom history stack for Ctrl+Z / Ctrl+Shift+Z in markdown mode
- Shift+Enter hard breaks in source mode (inserts trailing spaces + newline)
- Ctrl+K link shortcut: global keybinding for inserting/editing links
- Link modal improvements: auto-prepends https://, preserves selection range
- Hard break serialization fix: trailing spaces preserved in markdown output
- Drag prevention: prevents native text drag (copy-instead-of-move issue in Tauri webview)
2026-02-18 19:30:02 +01:00
Yuri Karamian 564c28904f Update README: add AUR and APT install instructions, bump to v1.1.2 2026-02-17 00:54:52 +01:00
73 changed files with 5965 additions and 1002 deletions
+48 -25
View File
@@ -1,29 +1,50 @@
# 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.3-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.3)
### Linux ### Linux
#### Arch / Manjaro / Fedora / openSUSE #### Arch / Manjaro (AUR)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.1/HelixNotes_1.1.1_amd64.AppImage) ```bash
yay -S helixnotes-appimage-bin
```
#### Debian / Ubuntu / Mint #### Debian / Ubuntu / Mint (APT)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.1/HelixNotes_1.1.1_amd64.deb) — Ubuntu 22.04+ ```bash
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 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.3/HelixNotes_1.2.3_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.1/HelixNotes_1.1.1_x64-setup.exe) Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.1/HelixNotes_1.1.1_x64.dmg) — macOS 12+ (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.3/HelixNotes_1.2.3_android.apk)
--- ---
@@ -31,29 +52,24 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features ## Features
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode - 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)
- **Full-text search** powered by Tantivy - Outline panel, daily notes, tags, drag-and-drop
- **Math support** — KaTeX rendering for inline and block equations - Math (KaTeX), PDF preview, Obsidian import
- **Daily notes** — one-click button to create or open today's note - AI writing tools (Ollama / Anthropic / OpenAI)
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI) - Version history with diffs, automatic backups
- **Version history** — per-note snapshots with diff view - Multi-window, file associations, focus mode, view mode
- **Backups** — automatic zip-based vault backups - Themes, accent colors, fonts
- **PDF preview** — inline rendering of embedded PDFs - Local files, no cloud
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
- **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
## Tech Stack ## Tech Stack
- **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
@@ -77,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.2", "version": "1.2.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+844 -389
View File
File diff suppressed because it is too large Load Diff
+11 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.2" version = "1.2.4"
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"
@@ -20,14 +20,14 @@ tauri-plugin-log = "2"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" 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"
@@ -35,7 +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", features = ["json", "stream"] } 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"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
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"] }
+4 -3
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,8 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized", "core:window:allow-is-maximized",
"core:window:allow-set-decorations", "core:window:allow-set-decorations",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default", "dialog:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
@@ -44,7 +46,6 @@
{ "url": "mailto:*" } { "url": "mailto:*" }
] ]
}, },
"opener:allow-reveal-item-in-dir", "opener:allow-reveal-item-in-dir"
"updater:default"
] ]
} }
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only capabilities",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": [
"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
+260 -19
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)?);
search.rebuild(&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)?;
}
*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]
@@ -261,8 +287,11 @@ pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String
} }
#[tauri::command] #[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> { pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title) let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
operations::rename_note(&path, &new_title, &vault_path)
} }
#[tauri::command] #[tauri::command]
@@ -374,6 +403,128 @@ 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 with titles (fast title extraction, no YAML parsing)
let mut graph_nodes = Vec::new();
let mut title_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()
});
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len();
title_to_idx.insert(title_lower, 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)
let mut edges = Vec::new();
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
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();
if let Some(&target_idx) = title_to_idx.get(&link) {
if target_idx != source_idx {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
if edge_set.insert(key) {
edges.push(crate::types::GraphEdge { source: source_idx, target: 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]
@@ -448,6 +599,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]
@@ -1320,11 +1533,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 +1551,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 +1607,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 +1624,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,15 +1671,27 @@ pub fn ai_ask(
system_prompt, system_prompt,
user_message, user_message,
request_id, request_id,
base_url,
); );
Ok(()) Ok(())
} }
// ── Helpers ── // ── Helpers ──
static ANDROID_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub fn set_android_config_dir(path: std::path::PathBuf) {
let _ = ANDROID_CONFIG_DIR.set(path);
}
fn app_config_path() -> Result<std::path::PathBuf, String> { fn app_config_path() -> Result<std::path::PathBuf, String> {
let config_dir = dirs::config_dir().ok_or("Cannot find config directory")?; let app_dir = if let Some(config_dir) = dirs::config_dir() {
let app_dir = config_dir.join("helixnotes"); config_dir.join("helixnotes")
} else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() {
android_dir.join("helixnotes")
} else {
return Err("Config directory not available yet".to_string());
};
std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?;
Ok(app_dir.join("config.json")) Ok(app_dir.join("config.json"))
} }
@@ -1483,11 +1722,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")
.output() .args(["-Q", pkg])
.map(|o| o.status.success()) .output()
.unwrap_or(false) .map(|o| o.status.success())
.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() {
+200 -15
View File
@@ -8,32 +8,33 @@ mod types;
mod vault; mod vault;
use state::AppState; use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))]
use tauri::{ use tauri::{
image::Image, image::Image,
menu::{MenuBuilder, MenuItemBuilder}, menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder, tray::TrayIconBuilder,
Manager,
}; };
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let config = commands::load_app_config(); let config = commands::load_app_config();
#[cfg(not(target_os = "android"))]
let show_tray = config.show_tray_icon; let show_tray = config.show_tray_icon;
#[cfg(not(target_os = "android"))]
let close_to_tray = config.close_to_tray && show_tray; let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config); let app_state = AppState::new(config);
let mut builder = tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| { .setup(move |app| {
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
@@ -43,10 +44,48 @@ pub fn run() {
)?; )?;
} }
// On Android, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")]
{
if let Ok(config_dir) = app.path().config_dir() {
commands::set_android_config_dir(config_dir);
} else if let Ok(data_dir) = app.path().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"))]
if show_tray { if show_tray {
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)
@@ -57,6 +96,7 @@ 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::create_notebook, commands::create_notebook,
commands::rename_notebook, commands::rename_notebook,
@@ -72,6 +112,7 @@ 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,
@@ -80,6 +121,8 @@ pub fn run() {
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,
@@ -105,13 +148,134 @@ 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 close_to_tray { if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
builder = builder.on_window_event(|window, event| { let _ = responder.respond(
if let tauri::WindowEvent::CloseRequested { api, .. } = event { tauri::http::Response::builder()
api.prevent_close(); .status(400)
let _ = window.hide(); .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"))]
{
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") {
let _ = window.show();
let _ = window.unminimize();
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_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
if close_to_tray && window.label() == "main" {
api.prevent_close();
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();
}
}
}
}
_ => {}
} }
}); });
} }
@@ -121,6 +285,7 @@ pub fn run() {
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
#[cfg(not(target_os = "android"))]
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> { fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?; let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
@@ -159,3 +324,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()
}
+34 -15
View File
@@ -5,7 +5,10 @@ use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use tantivy::collector::TopDocs; use tantivy::collector::TopDocs;
#[cfg(not(target_os = "android"))]
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")]
use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser; use tantivy::query::QueryParser;
use tantivy::schema::*; use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument}; use tantivy::{Index, IndexWriter, TantivyDocument};
@@ -24,9 +27,6 @@ pub struct SearchIndex {
impl SearchIndex { impl SearchIndex {
pub fn new(vault_path: &str) -> Result<Self, String> { pub fn new(vault_path: &str) -> Result<Self, String> {
let index_dir = helixnotes_dir(vault_path).join("search_index");
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let mut schema_builder = Schema::builder(); let mut schema_builder = Schema::builder();
let path_field = schema_builder.add_text_field("path", STRING | STORED); let path_field = schema_builder.add_text_field("path", STRING | STORED);
let title_field = schema_builder.add_text_field("title", TEXT | STORED); let title_field = schema_builder.add_text_field("title", TEXT | STORED);
@@ -34,12 +34,27 @@ impl SearchIndex {
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED); let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
let schema = schema_builder.build(); let schema = schema_builder.build();
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?; // Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem)
let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?; // Desktop: use mmap directory for persistent index on disk
#[cfg(target_os = "android")]
let index = {
let dir = RamDirectory::create();
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
};
#[cfg(not(target_os = "android"))]
let index = {
let index_dir = helixnotes_dir(vault_path).join("search_index");
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
};
let writer = index #[cfg(target_os = "android")]
.writer(50_000_000) let heap_size = 15_000_000;
.map_err(|e| e.to_string())?; #[cfg(not(target_os = "android"))]
let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
Ok(Self { Ok(Self {
index, index,
@@ -62,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();
@@ -135,8 +152,10 @@ 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 = let query_parser = QueryParser::for_index(
QueryParser::for_index(&self.index, vec![self.title_field, self.body_field, self.tags_field]); &self.index,
vec![self.title_field, self.body_field, self.tags_field],
);
let query = query_parser let query = query_parser
.parse_query(query_str) .parse_query(query_str)
+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),
} }
} }
} }
+26 -2
View File
@@ -54,6 +54,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 +93,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 +134,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 +146,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 +166,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 +254,21 @@ 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,
}
+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 {
+373 -47
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Utc}; use chrono::{Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -59,11 +59,11 @@ 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) && !is_hidden(&e.path())
path.is_dir() && !is_hidden(&path)
}) })
.collect(); .collect();
@@ -74,6 +74,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,38 +85,83 @@ 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 {
path.file_name() path.file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.map(|n| n.starts_with('.')) .map(|n| {
n.starts_with('.')
|| matches!(
n,
"_res" | "_resources" | "_attachments" | "_assets" | "assets" | "node_modules"
)
})
.unwrap_or(false) .unwrap_or(false)
} }
@@ -123,37 +170,88 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
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());
} }
let md_files: Vec<PathBuf> = if notebook_path.is_some() { // On Android, use metadata-only scan (no file reads) for fast listing on FUSE
// Only direct children for a specific notebook #[cfg(target_os = "android")]
fs::read_dir(root) {
.map_err(|e| e.to_string())? let mut notes = Vec::new();
.filter_map(|e| e.ok()) // Log what read_dir sees
.map(|e| e.path()) if let Ok(rd) = fs::read_dir(root) {
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md")) let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
.collect() log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
} else { for (i, entry) in items.iter().enumerate().take(5) {
// Recursive for "All Notes" let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
WalkDir::new(root) log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
.into_iter() }
.filter_map(|e| e.ok()) } else {
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path))) log::info!("scan_notes: read_dir FAILED for {:?}", root);
.map(|e| e.path().to_path_buf()) }
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
};
// Process files in parallel with partial reads if notebook_path.is_some() {
let mut notes: Vec<NoteEntry> = md_files if let Ok(rd) = fs::read_dir(root) {
.par_iter() for entry in rd.filter_map(|e| e.ok()) {
.filter_map(|path| read_note_entry_fast(path, vault_root).ok()) let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
.collect(); 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);
}
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); #[cfg(not(target_os = "android"))]
Ok(notes) {
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
} else {
WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.filter_map(|e| e.ok())
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
};
let mut notes: Vec<NoteEntry> = md_files
.par_iter()
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
.collect();
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
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> {
@@ -161,6 +259,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())?;
@@ -308,10 +451,62 @@ pub fn create_note(
}) })
} }
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
let today = Local::now(); let today = Local::now();
let date_str = today.format("%Y-%m-%d").to_string(); let date_str = today.format("%Y-%m-%d").to_string();
let title = today.format("%B %d, %Y").to_string(); let locale = get_system_locale();
let title = today.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily"); let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -430,13 +625,13 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
Ok(()) Ok(())
} }
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> { pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path); let src = Path::new(path);
if !src.exists() { if !src.exists() {
return Err("Note does not exist".to_string()); return Err("Note does not exist".to_string());
} }
// Update frontmatter // Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?; let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src let filename = src
.file_name() .file_name()
@@ -444,6 +639,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
let old_title = meta.title.clone();
let old_path_str = src.to_string_lossy().to_string();
// Update frontmatter
meta.title = new_title.to_string(); meta.title = new_title.to_string();
meta.modified = Utc::now(); meta.modified = Utc::now();
if meta.id.is_empty() { if meta.id.is_empty() {
@@ -460,7 +659,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
fs::rename(src, &new_path).map_err(|e| e.to_string())?; fs::rename(src, &new_path).map_err(|e| e.to_string())?;
} }
Ok(new_path.to_string_lossy().to_string()) let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
Ok(new_path_str)
}
/// Walk all .md files in the vault and update wikilink references after a note rename.
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
fn update_wikilinks_after_rename(
vault_path: &str,
old_path: &str,
new_path: &str,
old_title: &str,
new_title: &str,
) {
let vault = Path::new(vault_path);
let vault_prefix = format!("{}/", vault_path);
// Compute vault-relative path refs (without .md) for path-based wikilink refs
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
.unwrap_or(old_path)
.strip_suffix(".md")
.unwrap_or(old_path);
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
.unwrap_or(new_path)
.strip_suffix(".md")
.unwrap_or(new_path);
// Check if another note with the same old_title exists in the vault.
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
// because we can't tell which [[Old Title]] ref points to the renamed note
// vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
return false;
}
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
let p_str = p.to_string_lossy();
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(old_title))
.unwrap_or(false)
});
for entry in WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut result = content.clone();
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
// Update all wikilink forms that reference the renamed note:
// Title-based rules only apply when the old title is unique in the vault.
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
}
// Path-based rules are always safe (paths are unique).
if old_rel_ref != new_rel_ref {
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
result = result.replace(
&format!("[[{}]]", old_rel_ref),
&format!("[[{}]]", new_rel_ref),
);
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
// Also update the alias if it matches the old title.
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
if old_title != new_title {
result = result.replace(
&format!("[[{}|{}]]", old_rel_ref, old_title),
&format!("[[{}|{}]]", new_rel_ref, new_title),
);
}
// For aliases that DON'T match the old title, just update the ref part
result = result.replace(
&format!("[[{}|", old_rel_ref),
&format!("[[{}|", new_rel_ref),
);
}
if result != content {
let _ = fs::write(path, &result);
}
}
} }
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> { pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.1.2", "version": "1.2.4",
"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/**"]
+112
View File
@@ -158,3 +158,115 @@ body.resizing .ProseMirror {
body.resizing { body.resizing {
user-select: none; user-select: none;
} }
/* ── Mobile (Android/iOS) ── */
@media (max-width: 768px) {
html,
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
::-webkit-scrollbar {
display: none;
}
.resize-handle {
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;
}
}
+11 -8
View File
@@ -1,11 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
%sveltekit.head% name="viewport"
</head> content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no, maximum-scale=1"
<body data-sveltekit-preload-data="hover"> />
<div style="display: contents">%sveltekit.body%</div> %sveltekit.head%
</body> </head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>
+22 -1
View File
@@ -38,6 +38,10 @@ 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");
} }
@@ -122,6 +126,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,
@@ -160,6 +168,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 });
} }
@@ -310,8 +326,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 +347,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");
}
+536 -57
View File
@@ -23,26 +23,70 @@
readOnly, readOnly,
activeNote, activeNote,
activeNotePath, activeNotePath,
activeNotebook,
editorDirty, editorDirty,
showInfo, showInfo,
showSettings, showSettings,
sourceMode sourceMode,
mobileView,
appConfig,
quickAccessPaths,
tags,
notes,
updateAvailable as globalUpdateAvailable,
settingsTab
} 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');
import { loadVaultState, saveVaultState, readNote, createDailyNote } from '$lib/api'; const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess } 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 type { VaultState, FileEvent } from '$lib/types'; import type { VaultState, FileEvent } from '$lib/types';
let sidebar: Sidebar; let sidebar: Sidebar;
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 navigatingFromHistory = false; let navigatingFromHistory = false;
let noteHistory: string[] = []; let noteHistory: string[] = [];
let noteHistoryIndex = -1; let noteHistoryIndex = -1;
function parseFrequencyMs(freq: string): number {
switch (freq) {
case '6h': return 6 * 60 * 60 * 1000;
case '12h': return 12 * 60 * 60 * 1000;
case '7d': return 7 * 24 * 60 * 60 * 1000;
case '24h': default: return 24 * 60 * 60 * 1000;
}
}
async function checkScheduledBackup() {
const config = get(appConfig);
if (!config?.backup_enabled) return;
const interval = parseFrequencyMs(config.backup_frequency);
const last = config.last_backup_time ? new Date(config.last_backup_time).getTime() : 0;
if (Date.now() - last >= interval) {
const unlisten = await listen('backup-done', (event: any) => {
if (event.payload?.success) {
const cur = get(appConfig);
if (cur) appConfig.set({ ...cur, last_backup_time: new Date().toISOString() });
}
unlisten();
});
try { await createBackup(); } catch (_) { unlisten(); }
}
}
// Track note navigation in history stack // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
@@ -64,6 +108,7 @@
noteHistoryIndex = newIndex; noteHistoryIndex = newIndex;
navigatingFromHistory = true; navigatingFromHistory = true;
readNote(path).then((content) => { readNote(path).then((content) => {
editor?.flushSave();
$activeNote = content; $activeNote = content;
$activeNotePath = path; $activeNotePath = path;
$editorDirty = false; $editorDirty = false;
@@ -73,9 +118,26 @@
}); });
} }
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,
@@ -98,32 +160,89 @@
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string) {
editor?.loadNote(path, content); editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor';
} }
function handleViewChanged() { function handleViewChanged() {
noteList?.refresh(true); noteList?.refresh();
if (isMobile) $mobileView = 'notelist';
} }
async function createAndFocusNote() { async function createAndFocusNote() {
await noteList?.handleCreateNote(); await noteList?.handleCreateNote();
editor?.focusTitle(); editor?.focusTitle();
if (isMobile) $mobileView = 'editor';
} }
async function handleDailyNote() { async function handleDailyNote() {
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(); noteList?.refresh();
sidebar?.refresh(); sidebar?.refresh();
if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
console.error('Failed to create/open daily note:', e); console.error('Failed to create/open daily note:', e);
} }
} }
// 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() {
if ($mobileView === 'editor') $mobileView = 'notelist';
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
}
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); } if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); } if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
@@ -170,10 +289,20 @@
e.preventDefault(); e.preventDefault();
editor?.forceSave(); editor?.forceSave();
} }
if (mod && e.key === 'k' && !e.shiftKey && $activeNotePath && !$sourceMode) {
e.preventDefault();
editor?.addLinkFromToolbar();
}
if (mod && e.shiftKey && e.key === 'M') { if (mod && e.shiftKey && e.key === 'M') {
e.preventDefault(); e.preventDefault();
$sourceMode = !$sourceMode; $sourceMode = !$sourceMode;
} }
if (mod && e.shiftKey && e.key === 'W') {
e.preventDefault();
if ($activeNotePath && $activeNote) {
openNoteWindow($activeNotePath, $activeNote.meta.title);
}
}
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;
@@ -200,98 +329,318 @@
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 {
unlistenFileChange = await listen<FileEvent>('file-changed', async () => {
await sidebar?.refresh();
await noteList?.refresh(true);
});
}
// 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
checkScheduledBackup();
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
}); });
onDestroy(() => { onDestroy(() => {
unlistenFileChange?.(); unlistenFileChange?.();
unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval);
}); });
</script> </script>
<svelte:window onkeydown={handleKeydown} onmousedown={handleMouseDown} /> <svelte:window onkeydown={handleKeydown} onmousedown={isMobile ? undefined : handleMouseDown} />
<div class="app-shell"> {#if isMobile}
{#if $focusMode} <!-- ═══ MOBILE LAYOUT ═══ -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="mobile-shell">
<div class="focus-topbar" class:macos={isMac} onmousedown={(e) => { if (!(e.target as HTMLElement).closest('button')) appWindow.startDragging(); }}> <!-- Mobile Header -->
<span class="focus-title">{$activeNote?.meta.title || 'Untitled'}</span> <div class="mobile-header">
<div class="focus-controls"> {#if $mobileView !== 'sidebar'}
<button class="focus-btn focus-active" onclick={() => ($focusMode = false)} title="Exit focus mode (Escape)"> <button class="mobile-header-btn" onclick={mobileBack}>
<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="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/> <path d="M15 18l-6-6 6-6"/>
</svg> </svg>
</button> </button>
<button class="focus-btn" class:focus-active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Switch to Edit Mode' : 'Switch to View Mode'}> {:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <div class="mobile-header-brand">
{#if $readOnly} <svg width="22" height="22" viewBox="0 0 48 48" fill="none">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> <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" />
</svg>
</div>
{/if}
{#if $mobileView !== 'editor'}
<span class="mobile-header-title">
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{$activeNotebook?.name || 'All Notes'}
{/if}
</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">
{#if $mobileView === 'editor'}
<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">
{#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="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
{/if}
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.openNoteSearch()} title="Find in note">
<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" 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>
</button>
{/if}
{#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">
<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"/>
</svg>
</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>
</button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
<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="3" /> <circle cx="12" cy="12" r="3" />
{:else} <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="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" /> </svg>
<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" /> </button>
<line x1="1" y1="1" x2="23" y2="23" />
{/if}
</svg>
</button>
{#if !isMac}
<button class="focus-btn" onmousedown={(e) => e.stopPropagation()} 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="focus-btn" onmousedown={(e) => e.stopPropagation()} onclick={() => appWindow.toggleMaximize()} title="Maximize">
<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>
</button>
<button class="focus-btn focus-close" onmousedown={(e) => e.stopPropagation()} 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>
{/if} {/if}
</div> </div>
</div> </div>
{:else}
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} /> <!-- Mobile Content -->
{/if} <div class="mobile-content">
<div class="app-layout"> <div class="mobile-panel" class:active={$mobileView === 'sidebar'}>
{#if !$focusMode}
<div class="sidebar-panel" style="width: {$sidebarCollapsed ? 44 : $sidebarWidth}px">
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
</div>
</div>
{#if !$sidebarCollapsed} </div>
<ResizeHandle onResize={handleSidebarResize} /> {:else}
<!-- ═══ DESKTOP LAYOUT ═══ -->
<div class="app-shell">
{#if $focusMode}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="focus-topbar" class:macos={isMac} onmousedown={(e) => { if (!(e.target as HTMLElement).closest('button')) appWindow.startDragging(); }}>
<span class="focus-title">{$activeNote?.meta.title || 'Untitled'}</span>
<div class="focus-controls">
<button class="focus-btn focus-active" onclick={() => ($focusMode = false)} title="Exit focus mode (Escape)">
<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="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/>
</svg>
</button>
<button class="focus-btn" class:focus-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>
{#if !isMac}
<button class="focus-btn" onmousedown={(e) => e.stopPropagation()} 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="focus-btn" onmousedown={(e) => e.stopPropagation()} onclick={() => appWindow.toggleMaximize()} title="Maximize">
<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>
</button>
<button class="focus-btn focus-close" onmousedown={(e) => e.stopPropagation()} 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>
{/if}
</div>
</div>
{:else}
<TitleBar onNewNote={createAndFocusNote} onDailyNote={handleDailyNote} />
{/if}
<div class="app-layout">
{#if !$focusMode}
<div class="sidebar-panel" style="width: {$sidebarCollapsed ? 44 : $sidebarWidth}px">
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
{#if !$sidebarCollapsed}
<ResizeHandle onResize={handleSidebarResize} />
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="editor-panel">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} /> <Editor bind:this={editor} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} />
{/if}
<div class="editor-panel">
<Editor bind:this={editor} />
</div> </div>
</div> </div>
</div> {/if}
<SearchPanel /> <SearchPanel />
<CommandPalette /> <CommandPalette />
@@ -299,6 +648,7 @@
<InfoPanel /> <InfoPanel />
<style> <style>
/* ═══ DESKTOP STYLES ═══ */
.app-shell { .app-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -390,4 +740,133 @@
.focus-topbar.macos { .focus-topbar.macos {
padding-left: 78px; padding-left: 78px;
} }
/* ═══ MOBILE STYLES ═══ */
.mobile-shell {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: var(--bg-primary);
}
.mobile-header {
display: flex;
align-items: center;
height: 52px;
padding: 0 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
gap: 4px;
}
.mobile-header-brand {
display: flex;
align-items: center;
padding: 0 8px;
}
.mobile-header-title {
flex: 1;
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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 {
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
}
.mobile-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border: none;
background: none;
color: var(--text-secondary);
border-radius: 8px;
cursor: pointer;
}
.mobile-header-btn svg {
width: 16px;
height: 16px;
}
.mobile-header-btn:active {
background: var(--bg-hover);
}
.mobile-header-btn.active {
color: var(--accent);
}
.mobile-content {
flex: 1;
overflow: hidden;
position: relative;
}
.mobile-panel {
position: absolute;
inset: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
display: flex;
flex-direction: column;
}
.mobile-panel.active {
visibility: visible;
pointer-events: auto;
}
.mobile-fab {
position: absolute;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer;
z-index: 10;
}
.mobile-fab:active {
transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style> </style>
File diff suppressed because it is too large Load Diff
+177 -98
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { getAllNoteTitles, readNote } from '$lib/api'; import { getGraphData } from '$lib/api';
import { appConfig, activeNotePath } from '$lib/stores/app'; import { activeNotePath } from '$lib/stores/app';
import type { NoteTitleEntry } from '$lib/types';
let { onclose, onnavigate }: { let { onclose, onnavigate }: {
onclose: () => void; onclose: () => void;
@@ -12,6 +11,9 @@
let canvas = $state<HTMLCanvasElement>(null!); let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true); let loading = $state(true);
// Start fetching data immediately — don't wait for canvas mount
const dataPromise = getGraphData();
interface GraphNode { interface GraphNode {
id: string; id: string;
title: string; title: string;
@@ -31,7 +33,6 @@
let edges: GraphEdge[] = []; let edges: GraphEdge[] = [];
let nodeIndexMap: Map<string, number> = new Map(); let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set(); let connectedSet: Set<number> = new Set();
let animFrame = 0;
let pan = { x: 0, y: 0 }; let pan = { x: 0, y: 0 };
let zoom = 1; let zoom = 1;
let dragging: GraphNode | null = null; let dragging: GraphNode | null = null;
@@ -41,75 +42,54 @@
let panStart = { x: 0, y: 0 }; let panStart = { x: 0, y: 0 };
let hoveredNode: GraphNode | null = null; let hoveredNode: GraphNode | null = null;
let glowPhase = 0; let glowPhase = 0;
let glowFrame = 0;
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g; // Cache computed styles — read once, not every frame
let cachedStyles: { border: string; text: string; textSec: string; accent: string } | null = null;
function getStyles() {
if (cachedStyles) return cachedStyles;
const style = getComputedStyle(document.documentElement);
cachedStyles = {
border: style.getPropertyValue('--border-color').trim() || '#444',
text: style.getPropertyValue('--text-primary').trim() || '#eee',
textSec: style.getPropertyValue('--text-tertiary').trim() || '#888',
accent: style.getPropertyValue('--accent').trim() || '#7b9bd4',
};
return cachedStyles;
}
async function buildGraph() { async function buildGraph() {
loading = true; loading = true;
try { try {
const titles = await getAllNoteTitles(); // Use the pre-fetched promise (started before canvas mount)
const titleMap = new Map<string, NoteTitleEntry>(); const data = await dataPromise;
for (const t of titles) {
titleMap.set(t.title.toLowerCase(), t);
}
// Create nodes
const w = canvas?.width ?? 800; const w = canvas?.width ?? 800;
const h = canvas?.height ?? 600; const h = canvas?.height ?? 600;
nodes = titles.map((t) => ({
id: t.title.toLowerCase(),
title: t.title,
path: t.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
}));
// Build index map for O(1) lookups // Build node index map
nodeIndexMap = new Map(); nodeIndexMap = new Map();
for (let i = 0; i < nodes.length; i++) { nodes = data.nodes.map((n, i) => {
nodeIndexMap.set(nodes[i].id, i); nodeIndexMap.set(n.title.toLowerCase(), i);
} return {
id: n.title.toLowerCase(),
title: n.title,
path: n.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
};
});
// Read all notes in parallel (batched) to extract [[wiki-links]] // Map edges and track connected nodes
const edgeSet = new Set<string>(); connectedSet = new Set();
edges = []; edges = data.edges.map(e => {
const BATCH_SIZE = 20; connectedSet.add(e.source);
for (let b = 0; b < nodes.length; b += BATCH_SIZE) { connectedSet.add(e.target);
const batch = nodes.slice(b, b + BATCH_SIZE); return { sourceIdx: e.source, targetIdx: e.target };
const results = await Promise.allSettled( });
batch.map(async (node) => {
const content = await readNote(node.path);
return { node, body: content.content || '' };
})
);
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { node, body } = result.value;
const nodeIdx = nodeIndexMap.get(node.id)!;
let match;
wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) {
// Handle Obsidian syntax: strip |alias, #heading, ^block
let rawLink = match[1].trim();
const pipeIdx = rawLink.indexOf('|');
if (pipeIdx >= 0) rawLink = rawLink.slice(0, pipeIdx).trim();
rawLink = rawLink.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
const linkTitle = rawLink.toLowerCase();
const targetIdx = nodeIndexMap.get(linkTitle);
if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey);
edges.push({ sourceIdx: nodeIdx, targetIdx });
connectedSet.add(nodeIdx);
connectedSet.add(targetIdx);
}
}
}
}
}
} catch (e) { } catch (e) {
console.error('Failed to build graph:', e); console.error('Failed to build graph:', e);
} }
@@ -121,17 +101,17 @@
if (!canvas || nodes.length === 0) return; if (!canvas || nodes.length === 0) return;
const activePath = $activeNotePath || ''; const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath); const activeNode = nodes.find(n => n.path === activePath);
// Only center on active note if it has connections
if (!activeNode) return; if (!activeNode) return;
const activeIdx = nodeIndexMap.get(activeNode.id); const activeIdx = nodeIndexMap.get(activeNode.id);
if (activeIdx === undefined || !connectedSet.has(activeIdx)) return;
// Gather the active node and its direct neighbors // Gather the active node and its direct neighbors
const neighborhood: GraphNode[] = [activeNode]; const neighborhood: GraphNode[] = [activeNode];
for (const edge of edges) { if (activeIdx !== undefined) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]); for (const edge of edges) {
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]); if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
}
} }
const w = canvas.width; const w = canvas.width;
@@ -193,18 +173,13 @@
} }
function startSimulation() { function startSimulation() {
if (animFrame) cancelAnimationFrame(animFrame); // Run a small batch synchronously for an instant first render
for (let i = 0; i < 30; i++) simulate();
// Run physics synchronously — no need to animate the settling // Center/fit immediately so the user sees something right away
for (let i = 0; i < 300; i++) {
simulate();
}
// Center on active note if it has links, otherwise fit all
const activePath = $activeNotePath || ''; const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath); const activeNode = nodes.find(n => n.path === activePath);
const activeIdx = activeNode ? nodeIndexMap.get(activeNode.id) : undefined; if (activeNode) {
if (activeIdx !== undefined && connectedSet.has(activeIdx)) {
centerOnActiveNote(); centerOnActiveNote();
} else { } else {
fitToView(); fitToView();
@@ -212,9 +187,21 @@
draw(); draw();
startGlowLoop(); startGlowLoop();
}
let glowFrame = 0; // Continue settling asynchronously in small batches
const totalRemaining = Math.min(270, Math.max(70, nodes.length * 2));
let done = 0;
function settle() {
if (done >= totalRemaining) return;
const batch = Math.min(20, totalRemaining - done);
for (let i = 0; i < batch; i++) simulate();
done += batch;
// Re-center while settling
if (activeNode) centerOnActiveNote(); else fitToView();
requestAnimationFrame(settle);
}
requestAnimationFrame(settle);
}
function startGlowLoop() { function startGlowLoop() {
if (glowFrame) cancelAnimationFrame(glowFrame); if (glowFrame) cancelAnimationFrame(glowFrame);
@@ -235,16 +222,19 @@
const centerX = w / 2; const centerX = w / 2;
const centerY = h / 2; const centerY = h / 2;
// Repulsion between all nodes // Repulsion between all nodes (skip pairs that are very far apart)
for (let i = 0; i < nodeCount; i++) { for (let i = 0; i < nodeCount; i++) {
const a = nodes[i]; const a = nodes[i];
for (let j = i + 1; j < nodeCount; j++) { for (let j = i + 1; j < nodeCount; j++) {
const b = nodes[j]; const b = nodes[j];
const dx = b.x - a.x; const dx = b.x - a.x;
const dy = b.y - a.y; const dy = b.y - a.y;
const distSq = dx * dx + dy * dy || 1; const distSq = dx * dx + dy * dy;
const force = 800 / distSq; // Skip very distant pairs — negligible force
const dist = Math.sqrt(distSq); if (distSq > 250000) continue;
const d = distSq || 1;
const force = 800 / d;
const dist = Math.sqrt(d);
const fx = (dx / dist) * force; const fx = (dx / dist) * force;
const fy = (dy / dist) * force; const fy = (dy / dist) * force;
a.vx -= fx; a.vx -= fx;
@@ -254,7 +244,7 @@
} }
} }
// Attraction along edges (indexed lookups) // Attraction along edges
for (const edge of edges) { for (const edge of edges) {
const a = nodes[edge.sourceIdx]; const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx]; const b = nodes[edge.targetIdx];
@@ -298,24 +288,20 @@
ctx.translate(pan.x, pan.y); ctx.translate(pan.x, pan.y);
ctx.scale(zoom, zoom); ctx.scale(zoom, zoom);
const style = getComputedStyle(document.documentElement); const { border: borderColor, text: textColor, textSec: textSecondary, accent } = getStyles();
const borderColor = style.getPropertyValue('--border-color').trim() || '#444';
const textColor = style.getPropertyValue('--text-primary').trim() || '#eee';
const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888';
const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4';
// Draw edges // Draw edges
ctx.strokeStyle = borderColor; ctx.strokeStyle = borderColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.globalAlpha = 0.4; ctx.globalAlpha = 0.4;
ctx.beginPath();
for (const edge of edges) { for (const edge of edges) {
const a = nodes[edge.sourceIdx]; const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx]; const b = nodes[edge.targetIdx];
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
ctx.stroke();
} }
ctx.stroke();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
// Determine active note // Determine active note
@@ -369,7 +355,7 @@
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
// Label // Label — only for connected/active/hovered nodes
if (isActive || isHovered || hasLinks) { if (isActive || isHovered || hasLinks) {
ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`; ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = isActive || isHovered ? textColor : textSecondary; ctx.fillStyle = isActive || isHovered ? textColor : textSecondary;
@@ -465,6 +451,84 @@
if (e.key === 'Escape') onclose(); if (e.key === 'Escape') onclose();
} }
// Touch support for mobile
let lastTouchDist = 0;
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
const t = e.touches[0];
mouseDownPos = { x: t.clientX, y: t.clientY };
dragMoved = false;
const node = getNodeAt(t.clientX, t.clientY);
if (node) {
dragging = node;
} else {
panning = true;
panStart = { x: t.clientX - pan.x, y: t.clientY - pan.y };
}
} else if (e.touches.length === 2) {
dragging = null;
panning = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
}
}
function handleTouchMove(e: TouchEvent) {
e.preventDefault();
if (e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - mouseDownPos.x;
const dy = t.clientY - mouseDownPos.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
if (dragging && dragMoved) {
const rect = canvas.getBoundingClientRect();
dragging.x = (t.clientX - rect.left - pan.x) / zoom;
dragging.y = (t.clientY - rect.top - pan.y) / zoom;
dragging.vx = 0;
dragging.vy = 0;
draw();
} else if (panning) {
pan.x = t.clientX - panStart.x;
pan.y = t.clientY - panStart.y;
draw();
}
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (lastTouchDist > 0) {
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const rect = canvas.getBoundingClientRect();
const mx = midX - rect.left;
const my = midY - rect.top;
const oldZoom = zoom;
zoom = Math.max(0.2, Math.min(5, zoom * (dist / lastTouchDist)));
pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
pan.y = my - (my - pan.y) * (zoom / oldZoom);
draw();
}
lastTouchDist = dist;
}
}
function handleTouchEnd(e: TouchEvent) {
if (e.touches.length === 0) {
if (dragging && !dragMoved) {
const node = dragging;
dragging = null;
onnavigate(node.path, node.title);
return;
}
dragging = null;
panning = false;
lastTouchDist = 0;
}
}
$effect(() => { $effect(() => {
if (canvas) { if (canvas) {
const rect = canvas.parentElement?.getBoundingClientRect(); const rect = canvas.parentElement?.getBoundingClientRect();
@@ -477,7 +541,6 @@
}); });
onDestroy(() => { onDestroy(() => {
if (animFrame) cancelAnimationFrame(animFrame);
if (glowFrame) cancelAnimationFrame(glowFrame); if (glowFrame) cancelAnimationFrame(glowFrame);
}); });
</script> </script>
@@ -516,6 +579,9 @@
onmousemove={handleMouseMove} onmousemove={handleMouseMove}
onmouseup={handleMouseUp} onmouseup={handleMouseUp}
onwheel={handleWheel} onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
></canvas> ></canvas>
</div> </div>
</div> </div>
@@ -555,6 +621,20 @@
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 768px) {
.graph-panel {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
border: none;
}
.graph-header {
padding-top: calc(env(safe-area-inset-top, 36px) + 14px);
}
}
.graph-header h3 { .graph-header h3 {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -594,6 +674,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: grab; cursor: grab;
touch-action: none;
} }
.graph-canvas:active { .graph-canvas:active {
@@ -604,21 +685,19 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 10px;
color: var(--text-tertiary);
font-size: 13px; font-size: 13px;
color: var(--text-tertiary);
z-index: 1; z-index: 1;
} }
.spinner { .spinner {
animation: spin 0.8s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
</style> </style>
+25 -3
View File
@@ -6,9 +6,10 @@
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">
@@ -126,6 +129,8 @@
<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">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> <div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div> <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>
@@ -164,7 +169,8 @@
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
width: 500px; width: 500px;
max-height: 80vh; height: 80vh;
max-height: 600px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -201,7 +207,7 @@
} }
.info-body { .info-body {
padding: 0 24px 32px; padding: 0 24px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -244,6 +250,7 @@
} }
.info-logo { .info-logo {
margin-top: 12px;
margin-bottom: 8px; margin-bottom: 8px;
} }
@@ -394,4 +401,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>
+412 -16
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,
@@ -32,14 +33,38 @@
getAllTags getAllTags
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import type { NoteEntry, SortMode } from '$lib/types'; import type { NoteEntry, SortMode } from '$lib/types';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => 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 isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false);
/** 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);
@@ -57,6 +82,52 @@
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false); let batchTagEdit = $state(false);
// Mobile long-press selection
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressTriggered = false;
let touchStartPos = { x: 0, y: 0 };
const LONG_PRESS_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
function handleTouchStart(e: TouchEvent, note: NoteEntry) {
longPressTriggered = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
longPressTimer = setTimeout(() => {
longPressTriggered = true;
// Vibrate for haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
const next = new Set(selectedPaths);
if (next.size === 0 && $activeNotePath && $activeNotePath !== note.path) {
next.add($activeNotePath);
}
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
}, LONG_PRESS_MS);
}
function handleTouchMove(e: TouchEvent) {
if (!longPressTimer) return;
const touch = e.touches[0];
const dx = touch.clientX - touchStartPos.x;
const dy = touch.clientY - touchStartPos.y;
if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy) > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Quick Access drag-to-reorder // Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
@@ -85,6 +156,7 @@
selectedPaths = new Set(); selectedPaths = new Set();
lastClickedPath = null; lastClickedPath = null;
batchMovePicker = false; batchMovePicker = false;
multiSelectMode = false;
} }
// Clear selection and reset scroll when view/notebook changes // Clear selection and reset scroll when view/notebook changes
@@ -166,7 +238,12 @@
} 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;
@@ -179,9 +256,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;
@@ -320,7 +401,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);
} }
@@ -356,7 +437,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);
} }
@@ -378,7 +459,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);
} }
@@ -445,9 +526,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) {
@@ -456,7 +546,30 @@
editValue = note.meta.title; editValue = note.meta.title;
} }
function getNotebookPath(note: NoteEntry): string {
if ($viewMode === 'notebook') return '';
const parts = note.relative_path.replace(/\.md$/, '').split('/');
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
}
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);
@@ -552,7 +665,11 @@
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
sortMenu = { x: rect.left, y: rect.bottom + 4 }; const menuWidth = isMobile ? 220 : 180;
let x = rect.left;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (x < 4) x = 4;
sortMenu = { x, y: rect.bottom + 4 };
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
@@ -575,18 +692,25 @@
} }
}} /> }} />
<div class="note-list"> <div class="note-list" class:mobile={isMobile}>
<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>
@@ -603,6 +727,7 @@
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button>
{:else} {:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button> <button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
<button class="selection-action danger" onclick={handleBatchDelete}>Delete</button> <button class="selection-action danger" onclick={handleBatchDelete}>Delete</button>
{/if} {/if}
<button class="selection-close" onclick={clearSelection} title="Clear selection"> <button class="selection-close" onclick={clearSelection} title="Clear selection">
@@ -641,6 +766,50 @@
</div> </div>
{/if} {/if}
{#if batchTagEdit && !contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-overlay" onclick={() => batchTagEdit = false}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="batch-move-picker" onclick={(e) => e.stopPropagation()}>
<div class="batch-move-header">
<span>Tags for {selectedPaths.size} notes</span>
<button class="selection-close" onclick={() => batchTagEdit = false}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/>
</div>
{#if tagEditTags.length > 0}
<div class="tag-edit-list">
{#each tagEditTags as tag}
<div class="tag-edit-item">
<span class="tag-edit-name">#{tag}</span>
<button class="tag-edit-remove" onclick={() => removeTagFromBatch(tag)} title="Remove from all">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
{/each}
</div>
{:else}
<div class="context-empty">No common tags</div>
{/if}
</div>
</div>
</div>
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $sortedNotes.length === 0} {#if $sortedNotes.length === 0}
<div class="empty-state"> <div class="empty-state">
@@ -679,18 +848,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) => {
@@ -728,7 +902,7 @@
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
> >
{#if compact} {#if compact}
<div class="note-compact-row"> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
<span class="note-title"> <span class="note-title">
{#if note.meta.pinned} {#if note.meta.pinned}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pin-icon"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pin-icon">
@@ -737,10 +911,13 @@
{/if} {/if}
{note.meta.title} {note.meta.title}
</span> </span>
{#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span>
{/if}
<span class="note-date-compact">{formatRelativeTime(note.meta.modified)}</span> <span class="note-date-compact">{formatRelativeTime(note.meta.modified)}</span>
</div> </div>
{:else} {:else}
<div class="note-title"> <div class="note-title" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
{#if note.meta.pinned} {#if note.meta.pinned}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pin-icon"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pin-icon">
<path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/> <path d="M12 17v5"/><path d="M9 2h6l-1 7h4l-2 4H8l-2-4h4L9 2z"/>
@@ -750,6 +927,10 @@
</div> </div>
<div class="note-preview">{note.preview}</div> <div class="note-preview">{note.preview}</div>
<div class="note-meta"> <div class="note-meta">
{#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span>
<span class="note-meta-sep">&middot;</span>
{/if}
<span class="note-date">{formatRelativeTime(note.meta.modified)}</span> <span class="note-date">{formatRelativeTime(note.meta.modified)}</span>
{#if note.meta.tags.length > 0} {#if note.meta.tags.length > 0}
<span class="note-tags"> <span class="note-tags">
@@ -922,6 +1103,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"/>
@@ -934,6 +1121,14 @@
</svg> </svg>
Move to... Move to...
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
Print / Export to PDF
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.note)}> <button class="danger" onclick={() => handleDelete(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/> <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
@@ -1005,6 +1200,7 @@
.list-actions { .list-actions {
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
} }
@@ -1024,6 +1220,11 @@
color: var(--text-primary); color: var(--text-primary);
} }
.icon-btn.active-toggle {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.list-content { .list-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -1242,6 +1443,26 @@
margin-top: 4px; margin-top: 4px;
} }
.note-notebook {
font-size: 0.78em;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.note-meta-sep {
color: var(--text-tertiary);
font-size: 0.78em;
}
.note-compact-row .note-notebook {
font-size: 10px;
max-width: 80px;
flex-shrink: 0;
}
.note-date { .note-date {
font-size: 0.78em; font-size: 0.78em;
color: var(--text-tertiary); color: var(--text-tertiary);
@@ -1475,4 +1696,179 @@
.note-item.qa-drag-below { .note-item.qa-drag-below {
border-bottom: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
} }
/* ═══ MOBILE (class-based for Android high-DPI) ═══ */
.note-list.mobile {
border-right: none;
}
.note-list.mobile .list-header {
padding: 12px 16px;
}
.note-list.mobile .list-actions {
gap: 10px;
}
.note-list.mobile .list-title {
font-size: 16px;
}
.note-list.mobile .icon-btn {
min-width: 44px;
min-height: 44px;
padding: 10px;
}
.note-list.mobile .list-content {
padding: 4px 8px 180px;
}
.note-list.mobile .note-item {
padding: 14px 16px;
min-height: 56px;
border-radius: 10px;
margin-bottom: 2px;
}
.note-list.mobile .note-item.compact {
padding: 10px 16px;
min-height: 48px;
}
.note-list.mobile .note-title {
font-size: 15px;
}
.note-list.mobile .note-preview {
font-size: 13px;
margin-top: 4px;
}
.note-list.mobile .note-meta {
margin-top: 6px;
}
.note-list.mobile .note-date {
font-size: 12px;
}
.note-list.mobile .mini-tag {
font-size: 11px;
padding: 2px 6px;
}
.note-list.mobile .rename-input {
padding: 10px 12px;
font-size: 15px;
}
.note-list.mobile .empty-state {
padding: 64px 24px;
font-size: 15px;
}
.note-list.mobile .btn-link {
font-size: 15px;
}
.note-list.mobile .selection-bar {
padding: 8px 16px;
border-radius: 12px;
margin: 4px 8px;
border-bottom: none;
}
.note-list.mobile .selection-count {
font-size: 13px;
}
.note-list.mobile .selection-action {
padding: 6px 14px;
font-size: 13px;
min-height: 36px;
border-radius: 10px;
}
.note-list.mobile .context-menu {
min-width: 220px;
border-radius: 12px;
padding: 6px;
}
.note-list.mobile .context-menu button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.note-list.mobile .sort-menu {
min-width: 220px;
border-radius: 12px;
padding: 6px;
}
.note-list.mobile .sort-menu button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.note-list.mobile .batch-move-picker {
min-width: 80vw;
max-width: 90vw;
max-height: 60vh;
}
.note-list.mobile .batch-move-header {
padding: 14px 16px;
font-size: 15px;
}
.note-list.mobile .batch-move-picker .move-picker-list button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
}
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.mobile-create-btn:active {
opacity: 0.8;
}
</style> </style>
+326
View File
@@ -0,0 +1,326 @@
<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.key === 's') {
e.preventDefault();
editor?.forceSave();
}
if (mod && e.shiftKey && e.key === 'M') {
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}
+288 -58
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 '@tauri-apps/plugin-opener';
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,15 +56,27 @@
updateMessage = null; updateMessage = null;
updateAvailable = null; updateAvailable = null;
try { try {
const update = await checkUpdate(); if (isMobile) {
if (update) { await checkForUpdateMobile();
updateObj = update; const global = $globalUpdateAvailable;
$globalUpdateObj = update; if (global) {
updateAvailable = { version: update.version, body: update.body, date: update.date }; updateAvailable = { version: global.version, body: global.body };
globalUpdateAvailable.set({ version: update.version, body: update.body }); updateMessage = { type: 'info', text: `Version ${global.version} is available!` };
updateMessage = { type: 'info', text: `Version ${update.version} is available!` }; } else {
updateMessage = { type: 'success', text: 'You are on the latest version.' };
}
} else { } else {
updateMessage = { type: 'success', text: 'You are on the latest version.' }; const { check: checkUpdate } = await import('@tauri-apps/plugin-updater');
const update = await checkUpdate();
if (update) {
updateObj = update;
$globalUpdateObj = update;
updateAvailable = { version: update.version, body: update.body, date: update.date };
globalUpdateAvailable.set({ version: update.version, body: update.body });
updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
} else {
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}` };
@@ -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,18 +667,13 @@
</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">
<div class="settings-section"> <div class="settings-section">
<h3>Title</h3> <h3>Title</h3>
<div class="setting-options"> <label class="setting-toggle">
<button class="option-btn" class:active={titleMode === 'input'} onclick={() => { titleMode = 'input'; saveGeneralSettings(); }}>Input</button>
<button class="option-btn" class:active={titleMode === 'heading'} onclick={() => { titleMode = 'heading'; saveGeneralSettings(); }}>Heading</button>
<button class="option-btn" class:active={titleMode === 'hidden'} onclick={() => { titleMode = 'hidden'; saveGeneralSettings(); }}>Hidden</button>
</div>
<span class="setting-hint">How the note title is displayed in the editor</span>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label"> <span class="setting-label">
<span class="setting-name">Hide title in note body</span> <span class="setting-name">Hide title in note body</span>
<span class="setting-desc">Hide the first heading when it matches the note title</span> <span class="setting-desc">Hide the first heading when it matches the note title</span>
@@ -627,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>
@@ -636,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>
@@ -660,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">
@@ -691,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">
@@ -754,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">
@@ -811,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"/>
@@ -820,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>
@@ -947,52 +1024,86 @@
<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}
<div class="settings-section"> {#if aiProvider === 'ollama'}
<h3>API Key</h3> <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>
<div class="ai-key-row"> {: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 <input
type={aiShowKey ? 'text' : 'password'} type="text"
class="ai-key-input" class="ai-key-input"
placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'} placeholder="http://localhost:11434"
value={aiApiKey} value={_ollamaBaseUrl}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; if (aiProvider === 'openai') _openaiKey = v; else _anthropicKey = v; }} oninput={(e) => { _ollamaBaseUrl = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings} onblur={saveAiSettings}
/> />
<button class="ai-key-toggle" onclick={() => aiShowKey = !aiShowKey} title={aiShowKey ? 'Hide' : 'Show'}> <p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p>
{#if aiShowKey}
<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="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div> </div>
{#if aiProvider === 'openai'} {:else}
<p class="setting-hint">Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="ai-link">platform.openai.com</a></p> <div class="settings-section">
{:else} <h3>API Key</h3>
<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> <div class="ai-key-row">
{/if} <input
</div> type={aiShowKey ? 'text' : 'password'}
class="ai-key-input"
placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
value={aiApiKey}
oninput={(e) => { const v = (e.target as HTMLInputElement).value; if (aiProvider === 'openai') _openaiKey = v; else _anthropicKey = v; }}
onblur={saveAiSettings}
/>
<button class="ai-key-toggle" onclick={() => aiShowKey = !aiShowKey} title={aiShowKey ? 'Hide' : 'Show'}>
{#if aiShowKey}
<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="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
{#if aiProvider === 'openai'}
<p class="setting-hint">Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="ai-link">platform.openai.com</a></p>
{:else}
<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}
</div>
{/if}
<div class="settings-section"> <div class="settings-section">
<h3>Model</h3> <h3>Model</h3>
<div class="ai-model-options"> {#if aiProvider === 'ollama'}
{#each aiModels as m} <input
<button type="text"
class="ai-model-btn" class="ai-key-input"
class:active={aiModel === m.value} placeholder="gemma3:4b"
onclick={() => { aiModel = m.value; saveAiSettings(); }} value={aiModel}
> oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }}
<span class="ai-model-name">{m.label}</span> onblur={saveAiSettings}
<span class="ai-model-desc">{m.desc}</span> />
</button> <p class="setting-hint">Enter the model name as shown by <code>ollama list</code></p>
{/each} {:else}
</div> <div class="ai-model-options">
{#each aiModels as m}
<button
class="ai-model-btn"
class:active={aiModel === m.value}
onclick={() => { aiModel = m.value; saveAiSettings(); }}
>
<span class="ai-model-name">{m.label}</span>
<span class="ai-model-desc">{m.desc}</span>
</button>
{/each}
</div>
{/if}
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -1010,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...
@@ -1033,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>
@@ -1099,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">
@@ -1147,7 +1269,8 @@
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
width: 620px; width: 620px;
max-height: 80vh; height: 80vh;
max-height: 700px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2012,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>
+256 -14
View File
@@ -28,6 +28,7 @@
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
let editingNotebook = $state<string | null>(null); let editingNotebook = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
@@ -50,11 +51,23 @@
export async function refresh() { export async function refresh() {
try { try {
$notebooks = await getNotebooks(); if (isMobile) {
$tags = await getAllTags(); // On mobile, parallelize and skip getAllTags (derive from $notes instead)
$notebookIcons = await getNotebookIcons(); const [nbs, icons, qaNotes] = await Promise.all([
const qaNotes = await getQuickAccess(); getNotebooks(),
$quickAccessPaths = qaNotes.map(n => n.relative_path); getNotebookIcons(),
getQuickAccess(),
]);
$notebooks = nbs;
$notebookIcons = icons;
$quickAccessPaths = qaNotes.map(n => n.relative_path);
} else {
$notebooks = await getNotebooks();
$tags = await getAllTags();
$notebookIcons = await getNotebookIcons();
const qaNotes = await getQuickAccess();
$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 +81,11 @@
} }
function selectNotebook(nb: NotebookEntry) { function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
selectAllNotes();
return;
}
$viewMode = 'notebook'; $viewMode = 'notebook';
$activeNotebook = nb; $activeNotebook = nb;
$activeTag = null; $activeTag = null;
@@ -95,18 +113,33 @@
onViewChanged(); onViewChanged();
} }
let newNotebookParent = $state<NotebookEntry | null>(null);
async function handleCreateNotebook() { async function handleCreateNotebook() {
if (!newNotebookName.trim()) return; if (!newNotebookName.trim()) return;
try { try {
await createNotebook(null, newNotebookName.trim()); const parentRel = newNotebookParent?.relative_path ?? null;
await createNotebook(parentRel, newNotebookName.trim());
newNotebookName = ''; newNotebookName = '';
showNewNotebook = false; showNewNotebook = false;
newNotebookParent = null;
await refresh(); await refresh();
} catch (e) { } catch (e) {
console.error('Failed to create notebook:', e); console.error('Failed to create notebook:', e);
} }
} }
function startNewSubNotebook(nb: NotebookEntry) {
contextMenu = null;
newNotebookParent = nb;
newNotebookName = '';
showNewNotebook = true;
// Expand parent so the new child is visible
if ($collapsedNotebooks.includes(nb.path)) {
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
}
}
async function handleRename(nb: NotebookEntry) { async function handleRename(nb: NotebookEntry) {
if (!editValue.trim() || editValue.trim() === nb.name) { if (!editValue.trim() || editValue.trim() === nb.name) {
editingNotebook = null; editingNotebook = null;
@@ -250,9 +283,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() {
@@ -262,7 +304,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() {
@@ -288,7 +331,8 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed}> <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">
@@ -308,6 +352,7 @@
</button> </button>
{/if} {/if}
</div> </div>
{/if}
{#if !$sidebarCollapsed} {#if !$sidebarCollapsed}
<nav class="sidebar-nav"> <nav class="sidebar-nav">
@@ -368,7 +413,14 @@
}} }}
> >
<span class="section-title">Notebooks</span> <span class="section-title">Notebooks</span>
<button class="icon-btn-sm" onclick={() => (showNewNotebook = !showNewNotebook)} title="New notebook"> <button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<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="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>
@@ -377,13 +429,16 @@
{#if showNewNotebook} {#if showNewNotebook}
<div class="new-notebook-input"> <div class="new-notebook-input">
{#if newNotebookParent}
<span class="new-notebook-parent">{newNotebookParent.name}/</span>
{/if}
<input <input
type="text" type="text"
bind:value={newNotebookName} bind:value={newNotebookName}
placeholder="Notebook name..." placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') handleCreateNotebook(); if (e.key === 'Enter') handleCreateNotebook();
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; } if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
}} }}
/> />
</div> </div>
@@ -394,9 +449,32 @@
{@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>
@@ -444,6 +522,10 @@
{#if contextMenu} {#if contextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}> <div class="context-menu" style="left: {contextMenu.x}px; top: {contextMenu.y}px" onmousedown={(e) => e.stopPropagation()}>
<button onclick={() => startNewSubNotebook(contextMenu!.notebook)}>
<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" /><line x1="12" y1="11" x2="12" y2="17" /><line x1="9" y1="14" x2="15" y2="14" /></svg>
New Sub-notebook
</button>
<button onclick={() => startRename(contextMenu!.notebook)}> <button onclick={() => startRename(contextMenu!.notebook)}>
<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="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
Rename Rename
@@ -699,9 +781,35 @@
.new-notebook-input { .new-notebook-input {
padding: 2px 8px; padding: 2px 8px;
display: flex;
align-items: center;
gap: 0;
border: 1px solid var(--accent);
border-radius: 4px;
background: var(--bg-primary);
overflow: hidden;
} }
.new-notebook-input input, .rename-input { .new-notebook-parent {
padding: 4px 0 4px 8px;
color: var(--text-tertiary);
font-size: inherit;
white-space: nowrap;
flex-shrink: 0;
}
.new-notebook-input input {
flex: 1;
min-width: 0;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: inherit;
outline: none;
}
.rename-input {
width: 100%; width: 100%;
padding: 4px 8px; padding: 4px 8px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
@@ -957,4 +1065,138 @@
.delete-confirm-btn:hover { .delete-confirm-btn:hover {
opacity: 0.9; opacity: 0.9;
} }
/* ═══ MOBILE (class-based for Android high-DPI) ═══ */
.sidebar.mobile {
width: 100% !important;
min-width: 100%;
border-right: none;
}
.sidebar.mobile.collapsed {
width: 100% !important;
min-width: 100%;
max-width: 100%;
}
.sidebar.mobile .sidebar-header {
padding: 10px 12px;
}
.sidebar.mobile .collapse-btn {
display: none;
}
.sidebar.mobile .nav-item {
padding: 12px 14px;
font-size: 15px;
min-height: 48px;
gap: 12px;
}
.sidebar.mobile .nav-item svg {
width: 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 {
padding: 12px 16px 6px;
min-height: 44px;
}
.sidebar.mobile .section-title {
font-size: 12px;
}
.sidebar.mobile .icon-btn,
.sidebar.mobile .icon-btn-sm {
min-width: 44px;
min-height: 44px;
padding: 10px;
}
.sidebar.mobile .notebook-item {
padding: 12px 12px;
min-height: 48px;
gap: 10px;
font-size: 15px;
}
.sidebar.mobile .notebook-icon {
width: 24px;
height: 24px;
}
.sidebar.mobile .new-notebook-input {
padding: 4px 12px;
}
.sidebar.mobile .new-notebook-parent {
padding: 10px 0 10px 12px;
font-size: 15px;
}
.sidebar.mobile .new-notebook-input input {
padding: 10px 12px;
font-size: 15px;
}
.sidebar.mobile .rename-input {
padding: 10px 12px;
font-size: 15px;
}
.sidebar.mobile .tag-item {
padding: 10px 16px;
min-height: 44px;
font-size: 14px;
}
.sidebar.mobile .sidebar-footer {
padding: 8px 12px;
display: none;
}
.sidebar.mobile .context-menu {
min-width: 200px;
border-radius: 12px;
padding: 6px;
}
.sidebar.mobile .context-menu button {
padding: 12px 16px;
font-size: 15px;
min-height: 44px;
border-radius: 8px;
}
.sidebar.mobile .delete-confirm {
max-width: calc(100vw - 40px);
padding: 24px;
}
.sidebar.mobile .delete-confirm h4 {
font-size: 16px;
}
.sidebar.mobile .delete-confirm p {
font-size: 14px;
}
.sidebar.mobile .delete-confirm-cancel,
.sidebar.mobile .delete-confirm-btn {
padding: 10px 20px;
font-size: 14px;
min-height: 44px;
}
</style> </style>
+272 -6
View File
@@ -6,15 +6,66 @@
import type { VaultConfig } from '$lib/types'; import type { VaultConfig } from '$lib/types';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMobile = /android|ios/i.test(navigator.userAgent);
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() {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' }); if (isMobile) {
if (selected) { await openSelectedVault(fullPath);
await openSelectedVault(selected as string); } else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
if (selected) {
await openSelectedVault(selected as string);
}
} }
} }
@@ -22,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;
@@ -35,7 +92,8 @@
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="vault-picker" onmousedown={(e) => { if (!(e.target as HTMLElement).closest('button, .picker-card')) appWindow.startDragging(); }}> <div class="vault-picker" class:mobile={isMobile} onmousedown={(e) => { if (!isMobile && !(e.target as HTMLElement).closest('button, .picker-card')) appWindow.startDragging(); }}>
{#if !isMobile}
<div class="window-controls"> <div class="window-controls">
<button class="window-close" onclick={() => appWindow.close()} title="Close"> <button class="window-close" onclick={() => appWindow.close()} title="Close">
<svg width="10" height="10" viewBox="0 0 10 10"> <svg width="10" height="10" viewBox="0 0 10 10">
@@ -44,6 +102,7 @@
</svg> </svg>
</button> </button>
</div> </div>
{/if}
<div class="picker-card"> <div class="picker-card">
<div class="logo"> <div class="logo">
<svg width="72" height="72" viewBox="0 0 48 48" fill="none"> <svg width="72" height="72" viewBox="0 0 48 48" fill="none">
@@ -58,7 +117,45 @@
</div> </div>
<h1>HelixNotes</h1> <h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p> <p class="subtitle">Local markdown notes</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 isMobile}
<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}
<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 error} {#if error}
<div class="error">{error}</div> <div class="error">{error}</div>
@@ -67,6 +164,8 @@
<button class="btn-primary" onclick={pickFolder} disabled={loading}> <button class="btn-primary" onclick={pickFolder} disabled={loading}>
{#if loading} {#if loading}
Opening... Opening...
{:else if isMobile}
Get Started
{:else} {:else}
Open Notes Folder Open Notes Folder
{/if} {/if}
@@ -95,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 {
@@ -133,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 {
@@ -260,4 +364,166 @@
text-overflow: ellipsis; text-overflow: ellipsis;
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 .btn-primary {
padding: 14px 20px;
font-size: 16px;
border-radius: 12px;
}
.mobile .description {
font-size: 14px;
}
.mobile h1 {
font-size: 28px;
}
.mobile .logo :global(svg) {
width: 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>
+37
View File
@@ -38,6 +38,11 @@ export const activeNotePath = writable<string | null>(null);
export const activeNotebook = writable<NotebookEntry | null>(null); export const activeNotebook = writable<NotebookEntry | null>(null);
export const activeTag = writable<string | null>(null); export const activeTag = writable<string | null>(null);
// Mobile state
export const mobileView = writable<"sidebar" | "notelist" | "editor">(
"sidebar",
);
// Editor state // Editor state
export const editorDirty = writable(false); export const editorDirty = writable(false);
export const sourceMode = writable(false); export const sourceMode = writable(false);
@@ -54,6 +59,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 {
@@ -68,6 +74,37 @@ 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
}
}
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode],
+2
View File
@@ -41,6 +41,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 +60,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;
+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);
});
}
+75 -26
View File
@@ -1,13 +1,15 @@
<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 { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, getInstallType } from '$lib/api'; import { openFile, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
const isMobile = /android|ios/i.test(navigator.userAgent);
// Reactively apply theme class to <html> whenever $theme changes // Reactively apply theme class to <html> whenever $theme changes
$effect(() => { $effect(() => {
const t = $theme; const t = $theme;
@@ -31,10 +33,44 @@
return resolved.join('/'); return resolved.join('/');
} }
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
// Internal .md note link — navigate within the app
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content });
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
}
}
// Detect install type and check for updates on startup // Detect install type and check for updates on startup
onMount(() => { onMount(() => {
getInstallType().then(t => installType.set(t)).catch(() => {}); if (isMobile) {
checkForUpdate(); installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
}
}); });
// Intercept all link clicks in capture phase to prevent webview navigation // Intercept all link clicks in capture phase to prevent webview navigation
@@ -44,37 +80,50 @@
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
resolveAndHandleLink(href);
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
} }
document.addEventListener('click', handleLinkClick, true); document.addEventListener('click', handleLinkClick, true);
return () => document.removeEventListener('click', handleLinkClick, true);
// On Android WebView, tapping a link can trigger native navigation before
// the JS click event fires (causing 404s for relative .md links).
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
// preventDefault to block the native navigation and handle it ourselves.
let touchStartY = 0;
function handleTouchStart(e: TouchEvent) {
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
resolveAndHandleLink(href);
}
if (isMobile) {
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', handleTouchEnd, true);
}
return () => {
document.removeEventListener('click', handleLinkClick, true);
if (isMobile) {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchend', handleTouchEnd, true);
}
};
}); });
</script> </script>
<svelte:document oncontextmenu={(e) => e.preventDefault()} /> <svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
<svelte:head> <svelte:head>
<title>HelixNotes</title> <title>HelixNotes</title>
+18 -1
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 {
await openVault(config.active_vault); // Note windows don't re-open the vault (already open in main process)
if (!noteWindowPath) {
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}
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler",
} },
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// To make changes to top-level options such as include and exclude, we recommend extending // To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }