Compare commits

..
1 Commits
Author SHA1 Message Date
Yuri Karamian b4771d1b39 v1.0.2 - Update banner, smart updater, install type detection 2026-02-10 00:51:42 +01:00
37 changed files with 893 additions and 5016 deletions
+13 -57
View File
@@ -1,70 +1,33 @@
# HelixNotes # HelixNotes
A local markdown note-taking app built with Tauri, SvelteKit, and Rust. A local-first markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem. No cloud, no lock-in.
No cloud, no lock-in.
## Download (v1.1.6) ## Download
### Linux | Platform | Download |
|----------|----------|
| Linux | [HelixNotes_1.0.1_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_amd64.AppImage) |
| Windows | [HelixNotes_1.0.1_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_x64-setup.exe) |
| macOS | Coming soon |
#### Arch / Manjaro (AUR) See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
```bash
yay -S helixnotes-appimage-bin
```
#### Debian / Ubuntu / Mint (APT)
```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.1.6/HelixNotes_1.1.6_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_amd64.deb) — Ubuntu 22.04+
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_x64-setup.exe) — Windows 10/11
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.6/HelixNotes_1.1.6_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
---
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
## Features ## Features
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting - **Markdown editor** with rich formatting toolbar, slash commands, and source mode
- **Wiki-links** — link notes with `[[Note Title]]` syntax - **Wiki-links** — link notes with `[[Note Title]]` syntax
- **Graph view** — visualize connections between your notes - **Graph view** — visualize connections between your notes
- **Outline panel** — heading navigation for long notes
- **Full-text search** powered by Tantivy - **Full-text search** powered by Tantivy
- **Math support** — KaTeX rendering for inline and block equations
- **Daily notes** — one-click button to create or open today's note
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI) - **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI)
- **Version history** — per-note snapshots with diff view - **Version history** — per-note snapshots with diff view
- **Backups** — automatic zip-based vault backups - **Backups** — automatic zip-based vault backups
- **PDF preview** — inline rendering of embedded PDFs - **PDF preview** — inline rendering of embedded PDFs
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
- **Obsidian import** — convert Obsidian wiki-links to standard markdown - **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Multi-window** — open notes in separate windows (right-click → "Open in New Window") - **Themes** — light/dark mode with customizable accent colors and fonts
- **File associations** — open .md files from your file manager directly in HelixNotes
- **Themes** — light/dark mode with customizable accent colors, fonts, and line height
- **Focus mode** — distraction-free writing - **Focus mode** — distraction-free writing
- **View mode** — read-only toggle for distraction-free reading - **Local-first** — everything stays on your machine
- **Local** — everything stays on your machine
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
## Tech Stack ## Tech Stack
@@ -94,13 +57,6 @@ 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](LICENSE)
+1 -3
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.7", "version": "1.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -57,9 +57,7 @@
"@tiptap/extension-underline": "^3.19.0", "@tiptap/extension-underline": "^3.19.0",
"@tiptap/pm": "^3.19.0", "@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0", "@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"katex": "^0.16.28",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-mark": "^4.0.0", "markdown-it-mark": "^4.0.0",
-25
View File
@@ -89,15 +89,9 @@ importers:
'@tiptap/starter-kit': '@tiptap/starter-kit':
specifier: ^3.19.0 specifier: ^3.19.0
version: 3.19.0 version: 3.19.0
'@types/katex':
specifier: ^0.16.8
version: 0.16.8
'@types/markdown-it': '@types/markdown-it':
specifier: ^14.1.2 specifier: ^14.1.2
version: 14.1.2 version: 14.1.2
katex:
specifier: ^0.16.28
version: 0.16.28
lowlight: lowlight:
specifier: ^3.3.0 specifier: ^3.3.0
version: 3.3.0 version: 3.3.0
@@ -942,9 +936,6 @@ packages:
'@types/hast@3.0.4': '@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/katex@0.16.8':
resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==}
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
@@ -981,10 +972,6 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} engines: {node: '>=6'}
commander@8.3.0:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
cookie@0.6.0: cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -1061,10 +1048,6 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
katex@0.16.28:
resolution: {integrity: sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg==}
hasBin: true
kleur@4.1.5: kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1989,8 +1972,6 @@ snapshots:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
'@types/katex@0.16.8': {}
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/markdown-it@14.1.2': '@types/markdown-it@14.1.2':
@@ -2016,8 +1997,6 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
commander@8.3.0: {}
cookie@0.6.0: {} cookie@0.6.0: {}
crelt@1.0.6: {} crelt@1.0.6: {}
@@ -2095,10 +2074,6 @@ snapshots:
jiti@2.6.1: {} jiti@2.6.1: {}
katex@0.16.28:
dependencies:
commander: 8.3.0
kleur@4.1.5: {} kleur@4.1.5: {}
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
+395 -512
View File
File diff suppressed because it is too large Load Diff
+4 -8
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.7" version = "1.0.2"
description = "Local markdown note-taking app" description = "Local-first markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
@@ -20,6 +20,7 @@ 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"
@@ -34,11 +35,6 @@ tokio = { version = "1", features = ["full"] }
dirs = "6" dirs = "6"
regex = "1" regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] } zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } reqwest = { version = "0.12", features = ["json", "stream"] }
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"
+3 -4
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", "note-*"], "windows": ["main"],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:window:default", "core:window:default",
@@ -12,8 +12,6 @@
"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",
@@ -46,6 +44,7 @@
{ "url": "mailto:*" } { "url": "mailto:*" }
] ]
}, },
"opener:allow-reveal-item-in-dir" "opener:allow-reveal-item-in-dir",
"updater:default"
] ]
} }
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only capabilities",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": [
"updater:default"
]
}
+21 -52
View File
@@ -6,7 +6,6 @@ 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,
@@ -16,7 +15,6 @@ 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();
@@ -25,22 +23,7 @@ pub fn ai_request(
"openai" => { "openai" => {
stream_openai( stream_openai(
&app, &app,
OPENAI_API_URL, &api_key,
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,
@@ -205,8 +188,7 @@ async fn stream_anthropic(
async fn stream_openai( async fn stream_openai(
app: &AppHandle, app: &AppHandle,
url: &str, api_key: &str,
api_key: Option<&str>,
model: &str, model: &str,
system_prompt: &str, system_prompt: &str,
user_message: &str, user_message: &str,
@@ -242,15 +224,10 @@ async fn stream_openai(
body["temperature"] = json!(0.7); body["temperature"] = json!(0.7);
} }
let mut req = client let response = client
.post(url) .post(OPENAI_API_URL)
.header("content-type", "application/json"); .header("Authorization", format!("Bearer {}", api_key))
.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
@@ -348,19 +325,9 @@ async fn stream_openai(
Ok(()) Ok(())
} }
pub async fn test_connection( pub async fn test_connection(provider: &str, api_key: &str, model: &str) -> Result<String, String> {
provider: &str,
api_key: &str,
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
match provider { match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await, "openai" => test_openai(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,
} }
} }
@@ -398,12 +365,19 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
} }
} }
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> { async fn test_openai(api_key: &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,
"max_tokens": 10, token_key: 10,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -412,15 +386,10 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
] ]
}); });
let mut req = client let response = client
.post(url) .post(OPENAI_API_URL)
.header("content-type", "application/json"); .header("Authorization", format!("Bearer {}", api_key))
.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
+12 -194
View File
@@ -2,7 +2,6 @@ use crate::search::SearchIndex;
use crate::state::AppState; use crate::state::AppState;
use crate::types::*; use crate::types::*;
use crate::vault::{operations, watcher}; use crate::vault::{operations, watcher};
use std::path::Path;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
// ── Vault Management ── // ── Vault Management ──
@@ -45,12 +44,6 @@ 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())?;
@@ -83,14 +76,6 @@ 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]
@@ -116,77 +101,6 @@ pub fn rename_notebook(path: String, new_name: String) -> Result<String, String>
operations::rename_notebook(&path, &new_name) operations::rename_notebook(&path, &new_name)
} }
#[tauri::command]
pub fn move_notebook(
state: State<'_, AppState>,
notebook_path: String,
dest_parent: String,
) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let old_relative = Path::new(&notebook_path)
.strip_prefix(vault_path.as_str())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_full_path = operations::move_notebook(&notebook_path, &dest_parent)?;
let new_relative = Path::new(&new_full_path)
.strip_prefix(vault_path.as_str())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
// Update Quick Access paths for all notes inside the moved notebook
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
let old_prefix = format!("{}/", old_relative);
let mut changed = false;
for path in qa.iter_mut() {
if path.starts_with(&old_prefix) {
*path = format!("{}/{}", new_relative, &path[old_prefix.len()..]);
changed = true;
}
}
if changed {
let _ = operations::save_quick_access(vault_path, &qa);
}
}
// Update notebook icon mappings
if let Ok(icons) = operations::load_notebook_icons(vault_path) {
let old_prefix = format!("{}/", old_relative);
let mut new_icons = std::collections::HashMap::new();
let mut changed = false;
for (key, value) in &icons {
if *key == old_relative {
new_icons.insert(new_relative.clone(), value.clone());
changed = true;
} else if key.starts_with(&old_prefix) {
let new_key = format!("{}/{}", new_relative, &key[old_prefix.len()..]);
new_icons.insert(new_key, value.clone());
changed = true;
} else {
new_icons.insert(key.clone(), value.clone());
}
}
if changed {
let icons_path = operations::helixnotes_dir(vault_path).join("notebook_icons.json");
if let Ok(data) = serde_json::to_string_pretty(&new_icons) {
let _ = std::fs::write(&icons_path, data);
}
}
}
// Rebuild search index (paths changed for all contained notes)
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.rebuild(vault_path);
}
}
Ok(new_full_path)
}
#[tauri::command] #[tauri::command]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -259,21 +173,6 @@ pub fn create_note(
Ok(entry) Ok(entry)
} }
#[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path)?;
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&entry.path);
}
}
Ok(entry)
}
#[tauri::command] #[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> { pub fn rename_note(path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title) operations::rename_note(&path, &new_title)
@@ -296,39 +195,8 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
} }
#[tauri::command] #[tauri::command]
pub fn move_note( pub fn move_note(note_path: String, dest_notebook: String) -> Result<String, String> {
state: State<'_, AppState>, operations::move_note(&note_path, &dest_notebook)
note_path: String,
dest_notebook: String,
) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
// Compute old relative path before move
let old_relative = Path::new(&note_path)
.strip_prefix(vault_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_full_path = operations::move_note(&note_path, &dest_notebook)?;
// Update quick access if the moved note was in it
if !old_relative.is_empty() {
if let Ok(mut qa) = operations::load_quick_access(vault_path) {
if let Some(pos) = qa.iter().position(|p| *p == old_relative) {
let new_relative = Path::new(&new_full_path)
.strip_prefix(vault_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !new_relative.is_empty() {
qa[pos] = new_relative;
let _ = operations::save_quick_access(vault_path, &qa);
}
}
}
}
Ok(new_full_path)
} }
// ── Tags ── // ── Tags ──
@@ -521,7 +389,6 @@ pub fn set_general_settings(
pdf_height: u32, pdf_height: u32,
title_mode: String, title_mode: String,
hide_title_in_body: bool, hide_title_in_body: bool,
show_line_numbers: bool,
default_view_mode: bool, default_view_mode: bool,
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool, close_to_tray: bool,
@@ -536,7 +403,6 @@ pub fn set_general_settings(
config.pdf_height = pdf_height; config.pdf_height = pdf_height;
config.title_mode = title_mode; config.title_mode = title_mode;
config.hide_title_in_body = hide_title_in_body; config.hide_title_in_body = hide_title_in_body;
config.show_line_numbers = show_line_numbers;
config.default_view_mode = default_view_mode; config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon; config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray; config.close_to_tray = close_to_tray;
@@ -571,13 +437,6 @@ pub fn remove_quick_access(
operations::remove_quick_access(vault_path, &note_relative) operations::remove_quick_access(vault_path, &note_relative)
} }
#[tauri::command]
pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec<String>) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::save_quick_access(vault_path, &paths)
}
#[tauri::command] #[tauri::command]
pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> { pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -1113,9 +972,6 @@ fn resolve_wiki_ref(
reference: &str, reference: &str,
) -> String { ) -> String {
let reference = reference.trim(); let reference = reference.trim();
// Strip Obsidian heading anchors (#) and block references (^) before lookup
let reference = reference.split('#').next().unwrap_or(reference).trim();
let reference = reference.split('^').next().unwrap_or(reference).trim();
// Direct match by filename // Direct match by filename
if let Some(rel) = file_index.get(reference) { if let Some(rel) = file_index.get(reference) {
return rel.clone(); return rel.clone();
@@ -1334,13 +1190,11 @@ 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;
@@ -1352,36 +1206,27 @@ 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, base_url) = { let (provider, api_key, model) = {
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 == "ollama" { let key = if provider == "openai" {
// 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();
let base_url = config.ollama_base_url.clone(); (provider, key, model)
(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( let result = rt.block_on(crate::ai::test_connection(&provider, &api_key, &model));
&provider,
&api_key,
&model,
base_url.as_deref(),
));
match result { match result {
Ok(msg) => { Ok(msg) => {
let _ = app.emit( let _ = app.emit(
@@ -1408,16 +1253,14 @@ 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, base_url) = { let (provider, api_key, model, writing_style) = {
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 == "ollama" { let key = if provider == "openai" {
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()
@@ -1425,8 +1268,7 @@ 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();
let base_url = config.ollama_base_url.clone(); (provider, key, model, style)
(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. \
@@ -1472,27 +1314,15 @@ 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 app_dir = if let Some(config_dir) = dirs::config_dir() { let config_dir = dirs::config_dir().ok_or("Cannot find config directory")?;
config_dir.join("helixnotes") let app_dir = 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"))
} }
@@ -1516,20 +1346,8 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
#[tauri::command] #[tauri::command]
pub fn get_install_type() -> String { pub fn get_install_type() -> String {
if cfg!(target_os = "macos") { if cfg!(target_os = "windows") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
"windows".to_string() "windows".to_string()
} else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() {
"deb".to_string()
} else if std::path::Path::new("/var/lib/pacman/local").exists()
&& std::process::Command::new("pacman")
.args(["-Q", "helixnotes"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
"aur".to_string()
} else if std::env::var("APPIMAGE").is_ok() { } else if std::env::var("APPIMAGE").is_ok() {
"appimage".to_string() "appimage".to_string()
} else { } else {
+7 -100
View File
@@ -8,26 +8,17 @@ 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);
@@ -35,6 +26,7 @@ pub fn run() {
.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(
@@ -44,43 +36,10 @@ pub fn run() {
)?; )?;
} }
// On Android, set config dir from Tauri's path resolver
#[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);
}
}
#[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)
@@ -91,17 +50,14 @@ 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,
commands::delete_notebook, commands::delete_notebook,
commands::move_notebook,
commands::get_notes, commands::get_notes,
commands::read_note, commands::read_note,
commands::save_note, commands::save_note,
commands::create_note, commands::create_note,
commands::create_daily_note,
commands::rename_note, commands::rename_note,
commands::delete_note, commands::delete_note,
commands::move_note, commands::move_note,
@@ -123,7 +79,6 @@ pub fn run() {
commands::get_quick_access, commands::get_quick_access,
commands::add_quick_access, commands::add_quick_access,
commands::remove_quick_access, commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
@@ -140,60 +95,13 @@ 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,
]); ]);
#[cfg(not(target_os = "android"))] if close_to_tray {
{ builder = builder.on_window_event(|window, event| {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
// Always show/focus the main window api.prevent_close();
if let Some(window) = app.get_webview_window("main") { let _ = window.hide();
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.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();
}
}
}
}
_ => {}
} }
}); });
} }
@@ -203,7 +111,6 @@ 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)?;
+10 -27
View File
@@ -5,10 +5,7 @@ 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};
@@ -27,6 +24,9 @@ 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,27 +34,12 @@ 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();
// Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem) let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
// Desktop: use mmap directory for persistent index on disk let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?;
#[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())?
};
#[cfg(target_os = "android")] let writer = index
let heap_size = 15_000_000; .writer(50_000_000)
#[cfg(not(target_os = "android"))] .map_err(|e| e.to_string())?;
let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
Ok(Self { Ok(Self {
index, index,
@@ -150,10 +135,8 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?; let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher(); let searcher = reader.searcher();
let query_parser = QueryParser::for_index( let query_parser =
&self.index, QueryParser::for_index(&self.index, vec![self.title_field, self.body_field, self.tags_field]);
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)
-2
View File
@@ -9,7 +9,6 @@ pub struct AppState {
pub search_index: Mutex<Option<SearchIndex>>, pub search_index: Mutex<Option<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 {
@@ -19,7 +18,6 @@ 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),
} }
} }
} }
+2 -11
View File
@@ -54,8 +54,6 @@ 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,
@@ -72,8 +70,6 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub hide_title_in_body: bool, pub hide_title_in_body: bool,
#[serde(default)] #[serde(default)]
pub show_line_numbers: bool,
#[serde(default)]
pub backup_enabled: bool, pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")] #[serde(default = "default_backup_frequency")]
pub backup_frequency: String, pub backup_frequency: String,
@@ -93,8 +89,6 @@ 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)]
@@ -134,7 +128,7 @@ fn default_max_versions() -> u32 {
} }
fn default_ai_model() -> String { fn default_ai_model() -> String {
"claude-sonnet-4-6".to_string() "claude-sonnet-4-5-20250929".to_string()
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -146,7 +140,6 @@ 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,
@@ -155,7 +148,6 @@ impl Default for AppConfig {
pdf_height: 600, pdf_height: 600,
title_mode: "input".to_string(), title_mode: "input".to_string(),
hide_title_in_body: false, hide_title_in_body: false,
show_line_numbers: false,
backup_enabled: false, backup_enabled: false,
backup_frequency: "24h".to_string(), backup_frequency: "24h".to_string(),
backup_max_count: 10, backup_max_count: 10,
@@ -166,8 +158,7 @@ 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,
ollama_base_url: None, ai_model: "claude-sonnet-4-5-20250929".to_string(),
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,
+6 -116
View File
@@ -1,8 +1,9 @@
use crate::types::NoteMeta; use crate::types::NoteMeta;
use chrono::{NaiveDate, NaiveDateTime, Utc}; use chrono::Utc;
use gray_matter::engine::YAML; use gray_matter::engine::YAML;
use gray_matter::Matter; use gray_matter::Matter;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Default)] #[derive(Debug, Serialize, Deserialize, Default)]
struct RawFrontmatter { struct RawFrontmatter {
@@ -14,32 +15,6 @@ struct RawFrontmatter {
modified: Option<String>, modified: Option<String>,
} }
/// Try to parse a date string in multiple common formats.
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
let s = s.trim();
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
return Some(dt);
}
// Try parsing as DateTime with fixed offset then convert
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
// YYYY-MM-DD HH:MM:SS (no timezone)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
return Some(ndt.and_utc());
}
// YYYY-MM-DDTHH:MM:SS (no timezone, with T separator)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return Some(ndt.and_utc());
}
// YYYY-MM-DD (date only)
if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
}
None
}
pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) { pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
let matter = Matter::<YAML>::new(); let matter = Matter::<YAML>::new();
let result = matter.parse(raw); let result = matter.parse(raw);
@@ -53,16 +28,15 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
let created = fm let created = fm
.created .created
.and_then(|s| parse_date_flexible(&s)) .and_then(|s| s.parse().ok())
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
let modified = fm let modified = fm
.modified .modified
.and_then(|s| parse_date_flexible(&s)) .and_then(|s| s.parse().ok())
.unwrap_or_else(Utc::now); .unwrap_or_else(Utc::now);
// Don't generate UUID on read — empty string signals "no ID yet" let id = fm.id.unwrap_or_else(|| Uuid::new_v4().to_string());
let id = fm.id.unwrap_or_default();
let meta = NoteMeta { let meta = NoteMeta {
id, id,
@@ -77,29 +51,6 @@ 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()
@@ -127,68 +78,7 @@ 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);
let body_with_heading = ensure_title_heading(body, &meta.title); format!("{}{}", fm, body)
format!("{}{}", fm, body_with_heading)
}
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
/// Falls back to serialize_frontmatter() if the original has no frontmatter.
pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> String {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(original_raw);
// If original had no frontmatter, use the simple serializer
if parsed.matter.trim().is_empty() {
return update_note_raw(meta, body);
}
// Parse existing YAML into a Mapping to preserve all keys
let mut mapping: serde_yaml::Mapping = match serde_yaml::from_str(&parsed.matter) {
Ok(m) => m,
Err(_) => return update_note_raw(meta, body), // unparseable YAML, fall back
};
// Update only our known fields
if !meta.id.is_empty() {
mapping.insert(
serde_yaml::Value::String("id".into()),
serde_yaml::Value::String(meta.id.clone()),
);
}
mapping.insert(
serde_yaml::Value::String("title".into()),
serde_yaml::Value::String(meta.title.clone()),
);
mapping.insert(
serde_yaml::Value::String("tags".into()),
serde_yaml::Value::Sequence(
meta.tags
.iter()
.map(|t| serde_yaml::Value::String(t.clone()))
.collect(),
),
);
mapping.insert(
serde_yaml::Value::String("pinned".into()),
serde_yaml::Value::Bool(meta.pinned),
);
mapping.insert(
serde_yaml::Value::String("created".into()),
serde_yaml::Value::String(meta.created.to_rfc3339()),
);
mapping.insert(
serde_yaml::Value::String("modified".into()),
serde_yaml::Value::String(meta.modified.to_rfc3339()),
);
// Serialize the mapping back to YAML
let yaml_str = match serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping)) {
Ok(s) => s,
Err(_) => return update_note_raw(meta, 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 {
+9 -111
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::Utc;
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -114,13 +114,7 @@ fn count_notes_in_dir(dir: &Path) -> usize {
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| { .map(|n| n.starts_with('.'))
n.starts_with('.')
|| matches!(
n,
"_res" | "_resources" | "_attachments" | "_assets" | "assets" | "node_modules"
)
})
.unwrap_or(false) .unwrap_or(false)
} }
@@ -190,19 +184,13 @@ fn read_note_entry_from_str(
let (mut meta, content) = frontmatter::parse_note(raw, &filename); let (mut meta, content) = frontmatter::parse_note(raw, &filename);
// Use filesystem times only if frontmatter didn't have created/modified fields // Use filesystem times if frontmatter didn't have them
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
if let Ok(fs_meta) = fs::metadata(path) { if let Ok(fs_meta) = fs::metadata(path) {
if !has_modified { if let Ok(modified) = fs_meta.modified() {
if let Ok(modified) = fs_meta.modified() { meta.modified = modified.into();
meta.modified = modified.into();
}
} }
if !has_created { if let Ok(created) = fs_meta.created() {
if let Ok(created) = fs_meta.created() { meta.created = created.into();
meta.created = created.into();
}
} }
} }
@@ -245,19 +233,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
let mut updated_meta = meta.clone(); let mut updated_meta = meta.clone();
updated_meta.modified = Utc::now(); updated_meta.modified = Utc::now();
// Generate UUID on first save if note didn't have one let raw = frontmatter::update_note_raw(&updated_meta, body);
if updated_meta.id.is_empty() {
updated_meta.id = Uuid::new_v4().to_string();
}
// Read existing file to preserve unknown frontmatter fields
let existing = fs::read_to_string(path).unwrap_or_default();
let raw = if existing.is_empty() {
frontmatter::update_note_raw(&updated_meta, body)
} else {
frontmatter::merge_frontmatter(&existing, &updated_meta, body)
};
fs::write(path, raw).map_err(|e| e.to_string())?; fs::write(path, raw).map_err(|e| e.to_string())?;
Ok(()) Ok(())
} }
@@ -314,49 +290,6 @@ pub fn create_note(
}) })
} }
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
let today = Local::now();
let date_str = today.format("%Y-%m-%d").to_string();
let title = today.format("%B %d, %Y").to_string();
let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_path = dir.join(format!("{}.md", date_str));
let vault_root = Path::new(vault_path);
// If today's note already exists, return it
if file_path.exists() {
return read_note_entry(&file_path, vault_root);
}
let now = Utc::now();
let meta = NoteMeta {
id: Uuid::new_v4().to_string(),
title,
tags: vec!["daily".to_string()],
pinned: false,
created: now,
modified: now,
};
let raw = frontmatter::update_note_raw(&meta, "\n");
fs::write(&file_path, raw).map_err(|e| e.to_string())?;
let relative = file_path
.strip_prefix(vault_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();
Ok(NoteEntry {
path: file_path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview: String::new(),
})
}
pub fn create_notebook( pub fn create_notebook(
vault_path: &str, vault_path: &str,
parent_relative: Option<&str>, parent_relative: Option<&str>,
@@ -452,10 +385,7 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
let (mut meta, content) = frontmatter::parse_note(&raw, &filename); let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
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() { let updated = frontmatter::update_note_raw(&meta, &content);
meta.id = Uuid::new_v4().to_string();
}
let updated = frontmatter::merge_frontmatter(&raw, &meta, &content);
// Rename file // Rename file
let new_filename = sanitize_filename(new_title); let new_filename = sanitize_filename(new_title);
@@ -502,38 +432,6 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
Ok(dest.to_string_lossy().to_string()) Ok(dest.to_string_lossy().to_string())
} }
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
let src = Path::new(notebook_path);
if !src.exists() || !src.is_dir() {
return Err("Notebook does not exist".to_string());
}
let dest_parent_path = Path::new(dest_parent);
if !dest_parent_path.is_dir() {
return Err("Destination does not exist".to_string());
}
// No-op if already in that parent
if src.parent() == Some(dest_parent_path) {
return Err("Notebook is already in that location".to_string());
}
let dir_name = src.file_name().unwrap_or_default();
let dest = dest_parent_path.join(dir_name);
// Prevent moving into itself or a descendant
if dest.starts_with(src) {
return Err("Cannot move a notebook into itself or its descendants".to_string());
}
if dest.exists() {
return Err("A notebook with that name already exists in the destination".to_string());
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().to_string())
}
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> { pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash"); let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() { if !trash_dir.exists() {
+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.7", "version": "1.0.2",
"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 blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
-16
View File
@@ -1,16 +0,0 @@
{
"app": {
"windows": [
{
"label": "main",
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": {
"x": 12,
"y": 10
}
}
]
}
}
+1 -31
View File
@@ -31,7 +31,7 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--sidebar-width: 220px; --sidebar-width: 220px;
--notelist-width: 280px; --notelist-width: 280px;
--panel-resize-handle: 3px; --panel-resize-handle: 4px;
} }
:root.dark { :root.dark {
@@ -149,33 +149,3 @@ body {
.resize-handle.active { .resize-handle.active {
background: var(--accent); background: var(--accent);
} }
body.resizing .ProseMirror {
pointer-events: none;
contain: strict;
}
body.resizing {
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;
}
}
+8 -11
View File
@@ -1,14 +1,11 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta <meta name="viewport" content="width=device-width, initial-scale=1" />
name="viewport" %sveltekit.head%
content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no, maximum-scale=1" </head>
/> <body data-sveltekit-preload-data="hover">
%sveltekit.head% <div style="display: contents">%sveltekit.body%</div>
</head> </body>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>
+1 -27
View File
@@ -38,10 +38,6 @@ 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");
} }
@@ -64,13 +60,6 @@ export async function deleteNotebook(path: string): Promise<void> {
return invoke("delete_notebook", { path }); return invoke("delete_notebook", { path });
} }
export async function moveNotebook(
notebookPath: string,
destParent: string,
): Promise<string> {
return invoke("move_notebook", { notebookPath, destParent });
}
export async function getNotes( export async function getNotes(
notebookPath: string | null, notebookPath: string | null,
): Promise<NoteEntry[]> { ): Promise<NoteEntry[]> {
@@ -96,10 +85,6 @@ export async function createNote(
return invoke("create_note", { notebookRelative, title }); return invoke("create_note", { notebookRelative, title });
} }
export async function createDailyNote(): Promise<NoteEntry> {
return invoke("create_daily_note");
}
export async function renameNote( export async function renameNote(
path: string, path: string,
newTitle: string, newTitle: string,
@@ -195,7 +180,6 @@ export async function setGeneralSettings(
pdfHeight: number, pdfHeight: number,
titleMode: string, titleMode: string,
hideTitleInBody: boolean, hideTitleInBody: boolean,
showLineNumbers: boolean,
defaultViewMode: boolean, defaultViewMode: boolean,
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean, closeToTray: boolean,
@@ -210,7 +194,6 @@ export async function setGeneralSettings(
pdfHeight, pdfHeight,
titleMode, titleMode,
hideTitleInBody, hideTitleInBody,
showLineNumbers,
defaultViewMode, defaultViewMode,
showTrayIcon, showTrayIcon,
closeToTray, closeToTray,
@@ -230,10 +213,6 @@ export async function removeQuickAccess(noteRelative: string): Promise<void> {
return invoke("remove_quick_access", { noteRelative }); return invoke("remove_quick_access", { noteRelative });
} }
export async function reorderQuickAccess(paths: string[]): Promise<void> {
return invoke("reorder_quick_access", { paths });
}
export async function getVaultStats(): Promise<VaultStats> { export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats"); return invoke("get_vault_stats");
} }
@@ -314,9 +293,8 @@ 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, baseUrl }); return invoke("set_ai_settings", { provider, apiKey, model, writingStyle });
} }
export async function testAiConnection(): Promise<void> { export async function testAiConnection(): Promise<void> {
@@ -335,7 +313,3 @@ 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");
}
+42 -473
View File
@@ -20,108 +20,18 @@
showCommandPalette, showCommandPalette,
theme, theme,
focusMode, focusMode,
readOnly, activeNote
activeNote,
activeNotePath,
activeNotebook,
editorDirty,
showInfo,
showSettings,
sourceMode,
mobileView,
appConfig
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); import { loadVaultState, saveVaultState } from '$lib/api';
const isMobile = /android|ios/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile } 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;
let backupInterval: ReturnType<typeof setInterval> | null = null;
let navigatingFromHistory = false;
let noteHistory: string[] = [];
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
$effect(() => {
const path = $activeNotePath;
if (navigatingFromHistory) {
navigatingFromHistory = false;
return;
}
if (path) {
// Trim forward history and push
noteHistory = [...noteHistory.slice(0, noteHistoryIndex + 1), path];
noteHistoryIndex = noteHistory.length - 1;
}
});
function navigateHistory(direction: -1 | 1) {
const newIndex = noteHistoryIndex + direction;
if (newIndex < 0 || newIndex >= noteHistory.length) return;
const path = noteHistory[newIndex];
noteHistoryIndex = newIndex;
navigatingFromHistory = true;
readNote(path).then((content) => {
$activeNote = content;
$activeNotePath = path;
$editorDirty = false;
editor?.loadNote(path, content.content);
}).catch(() => {
// Note may have been deleted, ignore
});
}
async function handleOpenFile(filePath: string) {
if (!filePath || !filePath.endsWith('.md')) return;
const config = get(appConfig);
const vaultRoot = config?.active_vault;
if (!vaultRoot || !filePath.startsWith(vaultRoot)) return;
try {
const content = await readNote(filePath);
$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 = {
@@ -148,106 +58,39 @@
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(true);
if (isMobile) $mobileView = 'notelist';
} }
async function createAndFocusNote() { async function createAndFocusNote() {
if (isMobile && $mobileView !== 'notelist') $mobileView = 'notelist';
await noteList?.handleCreateNote(); await noteList?.handleCreateNote();
editor?.focusTitle(); editor?.focusTitle();
if (isMobile) $mobileView = 'editor';
}
async function handleDailyNote() {
try {
const entry = await createDailyNote();
const content = await readNote(entry.path);
$activeNote = content;
$activeNotePath = entry.path;
$editorDirty = false;
editor?.loadNote(entry.path, content.content);
noteList?.refresh();
sidebar?.refresh();
if (isMobile) $mobileView = 'editor';
} catch (e) {
console.error('Failed to create/open daily note:', e);
}
}
function mobileBack() {
if ($mobileView === 'editor') $mobileView = 'notelist';
else if ($mobileView === 'notelist') $mobileView = 'sidebar';
}
function handleMouseDown(e: MouseEvent) {
if (e.button === 3) { e.preventDefault(); navigateHistory(-1); }
if (e.button === 4) { e.preventDefault(); navigateHistory(1); }
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
const mod = e.ctrlKey || e.metaKey; if (e.ctrlKey && !e.shiftKey && e.key === 'n') {
if (e.altKey && e.key === 'ArrowLeft') {
e.preventDefault();
navigateHistory(-1);
return;
}
if (e.altKey && e.key === 'ArrowRight') {
e.preventDefault();
navigateHistory(1);
return;
}
if (mod && !e.shiftKey && e.key === 'n') {
e.preventDefault(); e.preventDefault();
createAndFocusNote(); createAndFocusNote();
} }
if (mod && e.shiftKey && e.key === 'N') { if (e.ctrlKey && e.shiftKey && e.key === 'N') {
e.preventDefault(); e.preventDefault();
} }
if (mod && e.shiftKey && e.key === 'F') { if (e.ctrlKey && e.key === 'f') {
e.preventDefault(); e.preventDefault();
$showSearch = true; $showSearch = true;
return;
} }
if (mod && !e.shiftKey && e.key === 'f') { if (e.ctrlKey && e.key === 'p') {
e.preventDefault();
if ($activeNotePath) {
editor?.openNoteSearch();
} else {
$showSearch = true;
}
return;
}
if (mod && e.key === 'p') {
e.preventDefault(); e.preventDefault();
$showCommandPalette = true; $showCommandPalette = true;
} }
if (mod && e.key === 's') { if (e.ctrlKey && e.key === 's') {
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') {
e.preventDefault();
$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 ($focusMode) $focusMode = false;
else if ($showInfo) $showInfo = false;
else if ($focusMode) $focusMode = false;
else if ($showSearch) $showSearch = false; else if ($showSearch) $showSearch = false;
else if ($showCommandPalette) $showCommandPalette = false; else if ($showCommandPalette) $showCommandPalette = false;
} }
@@ -292,211 +135,62 @@
await sidebar?.refresh(); await sidebar?.refresh();
await noteList?.refresh(true); 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={isMobile ? undefined : handleMouseDown} /> <svelte:window onkeydown={handleKeydown} />
{#if isMobile} <div class="app-shell">
<!-- ═══ MOBILE LAYOUT ═══ --> {#if $focusMode}
<div class="mobile-shell"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- Mobile Header --> <div class="focus-topbar" onmousedown={(e) => { if (!(e.target as HTMLElement).closest('button')) appWindow.startDragging(); }}>
<div class="mobile-header"> <span class="focus-title">{$activeNote?.meta.title || 'Untitled'}</span>
{#if $mobileView !== 'sidebar'} <div class="focus-controls">
<button class="mobile-header-btn" onclick={mobileBack}> <button class="focus-btn focus-active" onclick={() => ($focusMode = false)} title="Exit focus mode (Escape)">
<svg width="20" height="20" 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="M15 18l-6-6 6-6"/> <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> </svg>
</button> </button>
{:else} <button class="focus-btn" onmousedown={(e) => e.stopPropagation()} onclick={() => appWindow.minimize()} title="Minimize">
<div class="mobile-header-brand"> <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>
<svg width="22" height="22" 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" />
</svg>
</div>
{/if}
<span class="mobile-header-title">
{#if $mobileView === 'sidebar'}
HelixNotes
{:else if $mobileView === 'notelist'}
{$activeNotebook?.name || 'All Notes'}
{:else}
{$activeNote?.meta.title || 'Untitled'}
{/if}
</span>
<div class="mobile-header-actions">
{#if $mobileView === 'editor'}
<button class="mobile-header-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)}>
<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>
{:else}
<button class="mobile-header-btn" onclick={createAndFocusNote}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
{/if}
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
<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" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button> </button>
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings"> <button class="focus-btn" onmousedown={(e) => e.stopPropagation()} onclick={() => appWindow.toggleMaximize()} title="Maximize">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="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>
<circle cx="12" cy="12" r="3" /> </button>
<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" /> <button class="focus-btn focus-close" onmousedown={(e) => e.stopPropagation()} onclick={() => appWindow.close()} title="Close">
</svg> <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> </button>
</div> </div>
</div> </div>
{:else}
<!-- Mobile Content --> <TitleBar onNewNote={createAndFocusNote} />
<div class="mobile-content"> {/if}
<div class="mobile-panel" class:active={$mobileView === 'sidebar'}> <div class="app-layout">
{#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} onNoteMoved={() => sidebar?.refresh()} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
</div>
</div>
<!-- Mobile Bottom Nav --> {#if !$sidebarCollapsed}
{#if $mobileView !== 'editor'} <ResizeHandle onResize={handleSidebarResize} />
<div class="mobile-bottom-nav">
<button class="mobile-nav-item" class:active={$mobileView === 'sidebar'} onclick={() => ($mobileView = 'sidebar')}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
</svg>
<span>Notebooks</span>
</button>
<button class="mobile-nav-item" onclick={() => $showSearch = true}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span>Search</span>
</button>
<button class="mobile-nav-item" onclick={handleDailyNote}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<span>Daily</span>
</button>
<button class="mobile-nav-item" onclick={() => ($showSettings = true)}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 01-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" />
</svg>
<span>Settings</span>
</button>
</div>
{/if}
</div>
{: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} onNoteMoved={() => sidebar?.refresh()} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
{/if} {/if}
<div class="editor-panel"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<Editor bind:this={editor} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onNoteMoved={() => sidebar?.refresh()} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} />
{/if}
<div class="editor-panel">
<Editor bind:this={editor} />
</div> </div>
</div> </div>
{/if} </div>
<SearchPanel /> <SearchPanel />
<CommandPalette /> <CommandPalette />
@@ -504,7 +198,6 @@
<InfoPanel /> <InfoPanel />
<style> <style>
/* ═══ DESKTOP STYLES ═══ */
.app-shell { .app-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -592,128 +285,4 @@
background: #e81123; background: #e81123;
color: white; color: white;
} }
.focus-topbar.macos {
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-header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.mobile-header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border: none;
background: none;
color: var(--text-secondary);
border-radius: 10px;
cursor: pointer;
}
.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-bottom-nav {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
flex-shrink: 0;
padding-bottom: env(safe-area-inset-bottom);
}
.mobile-nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 6px 16px;
border: none;
background: none;
color: var(--text-tertiary);
font-size: 10px;
font-weight: 500;
cursor: pointer;
border-radius: 8px;
transition: color 0.15s;
}
.mobile-nav-item:active {
background: var(--bg-hover);
}
.mobile-nav-item.active {
color: var(--accent);
}
</style> </style>
+1 -3
View File
@@ -9,8 +9,6 @@
action: () => void; action: () => void;
} }
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
let query = $state(''); let query = $state('');
let selectedIndex = $state(0); let selectedIndex = $state(0);
let inputEl = $state<HTMLInputElement>(null!); let inputEl = $state<HTMLInputElement>(null!);
@@ -19,7 +17,7 @@
{ {
id: 'search', id: 'search',
label: 'Search Notes', label: 'Search Notes',
shortcut: `${modKey}+F`, shortcut: 'Ctrl+F',
action: () => { action: () => {
$showCommandPalette = false; $showCommandPalette = false;
$showSearch = true; $showSearch = true;
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -91,12 +91,7 @@
let match; let match;
wikiLinkRegex.lastIndex = 0; wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) { while ((match = wikiLinkRegex.exec(body)) !== null) {
// Handle Obsidian syntax: strip |alias, #heading, ^block const linkTitle = match[1].trim().toLowerCase();
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); const targetIdx = nodeIndexMap.get(linkTitle);
if (linkTitle !== node.id && targetIdx !== undefined) { if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`; const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
+12 -20
View File
@@ -5,8 +5,6 @@
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>('shortcuts'); let activeTab = $state<'about' | 'shortcuts'>('shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
@@ -73,7 +71,7 @@
</div> </div>
<h3 class="app-name">HelixNotes</h3> <h3 class="app-name">HelixNotes</h3>
<p class="app-version">v{appVersion}</p> <p class="app-version">v{appVersion}</p>
<p class="app-description">A local markdown note-taking app.</p> <p class="app-description">A local-first markdown note-taking app.</p>
{#if stats} {#if stats}
<div class="info-stats"> <div class="info-stats">
@@ -114,19 +112,15 @@
{:else} {:else}
<div class="shortcuts-section"> <div class="shortcuts-section">
<h4 class="shortcuts-group-title">Keyboard Shortcuts</h4> <h4 class="shortcuts-group-title">Keyboard Shortcuts</h4>
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>N</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>P</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Search</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>F</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>S</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>B</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>I</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>U</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Underline</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>U</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>Ctrl</kbd>+<kbd>Z</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div> <div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>Ctrl</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 forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd></kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Toggle source mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
<div class="shortcut-row"><span class="shortcut-desc">Exit focus mode</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div> <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>
@@ -165,8 +159,7 @@
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
width: 500px; width: 500px;
height: 80vh; max-height: 80vh;
max-height: 600px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -203,7 +196,7 @@
} }
.info-body { .info-body {
padding: 0 24px 16px; padding: 0 24px 32px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -246,7 +239,6 @@
} }
.info-logo { .info-logo {
margin-top: 12px;
margin-bottom: 8px; margin-bottom: 8px;
} }
+12 -523
View File
@@ -11,8 +11,7 @@
editorDirty, editorDirty,
quickAccessPaths, quickAccessPaths,
appConfig, appConfig,
notebooks, notebooks
tags
} from '$lib/stores/app'; } from '$lib/stores/app';
import { import {
getNotes, getNotes,
@@ -27,12 +26,9 @@
getQuickAccess, getQuickAccess,
addQuickAccess, addQuickAccess,
removeQuickAccess, removeQuickAccess,
reorderQuickAccess, moveNote
moveNote,
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 = () => {} }: {
@@ -40,48 +36,17 @@
onNoteMoved?: () => void; onNoteMoved?: () => void;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
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);
let sortMenu = $state<{ x: number; y: number } | null>(null); let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null); let editingNote = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
let movePickerNote = $state<NoteEntry | null>(null); let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null);
let tagEditValue = $state('');
let tagEditTags = $state<string[]>([]);
// Multi-select // Multi-select
let selectedPaths = $state<Set<string>>(new Set()); let selectedPaths = $state<Set<string>>(new Set());
let lastClickedPath = $state<string | null>(null); let lastClickedPath = $state<string | null>(null);
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false);
// Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
// Virtual scroll
let listContainer = $state<HTMLDivElement>(null!);
let scrollTop = $state(0);
let containerHeight = $state(600);
let itemHeight = $derived(compact ? 33 : 62);
const BUFFER = 10;
let totalHeight = $derived($sortedNotes.length * itemHeight);
let startIndex = $derived(Math.max(0, Math.floor(scrollTop / itemHeight) - BUFFER));
let endIndex = $derived(Math.min($sortedNotes.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + BUFFER));
let visibleNotes = $derived($sortedNotes.slice(startIndex, endIndex));
let topPad = $derived(startIndex * itemHeight);
let bottomPad = $derived(Math.max(0, ($sortedNotes.length - endIndex) * itemHeight));
function onListScroll(e: Event) {
const el = e.currentTarget as HTMLDivElement;
scrollTop = el.scrollTop;
}
function clearSelection() { function clearSelection() {
selectedPaths = new Set(); selectedPaths = new Set();
@@ -89,22 +54,10 @@
batchMovePicker = false; batchMovePicker = false;
} }
// Clear selection and reset scroll when view/notebook changes // Clear selection when view/notebook changes
$effect(() => { $effect(() => {
const _ = [$viewMode, $activeNotebook, $activeTag]; const _ = [$viewMode, $activeNotebook, $activeTag];
clearSelection(); clearSelection();
scrollTop = 0;
if (listContainer) listContainer.scrollTop = 0;
});
// Track container height via ResizeObserver
$effect(() => {
if (!listContainer) return;
const ro = new ResizeObserver((entries) => {
containerHeight = entries[0].contentRect.height;
});
ro.observe(listContainer);
return () => ro.disconnect();
}); });
interface FlatNotebook { name: string; path: string; depth: number; } interface FlatNotebook { name: string; path: string; depth: number; }
@@ -290,102 +243,6 @@
} }
} }
function openTagEdit(note: NoteEntry) {
tagEditNote = note;
tagEditTags = [...note.meta.tags];
tagEditValue = '';
}
async function addTagToNote(tag: string) {
if (!tagEditNote || !tag.trim()) return;
const cleaned = tag.trim().toLowerCase();
if (tagEditTags.includes(cleaned)) return;
tagEditTags = [...tagEditTags, cleaned];
tagEditValue = '';
await saveTagsForNote(tagEditNote, tagEditTags);
}
async function removeTagFromNote(tag: string) {
if (!tagEditNote) return;
tagEditTags = tagEditTags.filter(t => t !== tag);
await saveTagsForNote(tagEditNote, tagEditTags);
}
async function saveTagsForNote(note: NoteEntry, newTags: string[]) {
try {
const content = await readNote(note.path);
content.meta.tags = newTags;
await saveNote(note.path, content.meta, content.content);
$notes = $notes.map(n =>
n.path === note.path ? { ...n, meta: { ...n.meta, tags: newTags } } : n
);
if ($activeNotePath === note.path && $activeNote) {
$activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: newTags } };
}
$tags = await getAllTags();
} catch (e) {
console.error('Failed to save tags:', e);
}
}
function openBatchTagEdit() {
batchTagEdit = true;
tagEditValue = '';
// Show tags common to ALL selected notes
const selectedNotes = $notes.filter(n => selectedPaths.has(n.path));
if (selectedNotes.length === 0) { tagEditTags = []; return; }
tagEditTags = selectedNotes[0].meta.tags.filter(t =>
selectedNotes.every(n => n.meta.tags.includes(t))
);
}
async function addTagToBatch(tag: string) {
if (!tag.trim()) return;
const cleaned = tag.trim().toLowerCase();
tagEditValue = '';
if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned];
try {
for (const path of selectedPaths) {
const content = await readNote(path);
if (!content.meta.tags.includes(cleaned)) {
content.meta.tags = [...content.meta.tags, cleaned];
await saveNote(path, content.meta, content.content);
$notes = $notes.map(n =>
n.path === path ? { ...n, meta: { ...n.meta, tags: content.meta.tags } } : n
);
if ($activeNotePath === path) {
$activeNote = content;
}
}
}
$tags = await getAllTags();
} catch (e) {
console.error('Failed to add tag to batch:', e);
}
}
async function removeTagFromBatch(tag: string) {
tagEditTags = tagEditTags.filter(t => t !== tag);
try {
for (const path of selectedPaths) {
const content = await readNote(path);
if (content.meta.tags.includes(tag)) {
content.meta.tags = content.meta.tags.filter((t: string) => t !== tag);
await saveNote(path, content.meta, content.content);
$notes = $notes.map(n =>
n.path === path ? { ...n, meta: { ...n.meta, tags: content.meta.tags } } : n
);
if ($activeNotePath === path) {
$activeNote = content;
}
}
}
$tags = await getAllTags();
} catch (e) {
console.error('Failed to remove tag from batch:', e);
}
}
async function handleAddQuickAccess(note: NoteEntry) { async function handleAddQuickAccess(note: NoteEntry) {
contextMenu = null; contextMenu = null;
try { try {
@@ -403,33 +260,12 @@
await removeQuickAccess(note.relative_path); await removeQuickAccess(note.relative_path);
const qaNotes = await getQuickAccess(); const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
if ($viewMode === 'quickaccess') await refresh(true); if ($viewMode === 'quickaccess') await refresh();
} catch (e) { } catch (e) {
console.error('Failed to remove from quick access:', e); console.error('Failed to remove from quick access:', e);
} }
} }
async function handleQaDrop(targetIndex: number) {
if (qaDragFrom === null) { qaDragFrom = null; qaDragOver = null; return; }
// Compute the actual insert position based on which half we're hovering
let insertAt = qaDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
if (qaDragFrom === insertAt || qaDragFrom + 1 === insertAt) {
qaDragFrom = null; qaDragOver = null; return;
}
const arr = [...$sortedNotes];
const [moved] = arr.splice(qaDragFrom, 1);
if (insertAt > qaDragFrom) insertAt--;
arr.splice(insertAt, 0, moved);
$notes = arr;
qaDragFrom = null;
qaDragOver = null;
try {
await reorderQuickAccess(arr.map(n => n.relative_path));
} catch (e) {
console.error('Failed to reorder quick access:', e);
}
}
async function handleMoveNote(note: NoteEntry, destPath: string) { async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null; contextMenu = null;
movePickerNote = null; movePickerNote = null;
@@ -458,12 +294,6 @@
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) {
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Toggle individual selection // Toggle individual selection
@@ -553,7 +383,7 @@
} }
function handleWindowClick() { function handleWindowClick() {
if (contextMenu) { contextMenu = null; movePickerNote = null; tagEditNote = null; batchTagEdit = false; } if (contextMenu) { contextMenu = null; movePickerNote = null; }
if (sortMenu) sortMenu = null; if (sortMenu) sortMenu = null;
} }
@@ -583,7 +413,7 @@
} }
}} /> }} />
<div class="note-list" class:mobile={isMobile}> <div class="note-list">
<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">
@@ -593,7 +423,7 @@
</svg> </svg>
</button> </button>
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class="icon-btn" onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class="icon-btn" onclick={handleCreateNote} title="New note (Ctrl+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="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>
@@ -649,7 +479,7 @@
</div> </div>
{/if} {/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content">
{#if $sortedNotes.length === 0} {#if $sortedNotes.length === 0}
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
@@ -661,9 +491,7 @@
</div> </div>
{/if} {/if}
<div style="height: {topPad}px"></div> {#each $sortedNotes as note (note.path)}
{#each visibleNotes as note, i (note.path)}
{@const noteIndex = startIndex + i}
{#if editingNote === note.path} {#if editingNote === note.path}
<div class="note-item active"> <div class="note-item active">
<input <input
@@ -684,8 +512,6 @@
class:selected={selectedPaths.has(note.path)} class:selected={selectedPaths.has(note.path)}
class:pinned={note.meta.pinned} class:pinned={note.meta.pinned}
class:compact={compact} class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -702,12 +528,6 @@
}} }}
draggable="true" draggable="true"
ondragstart={(e) => { ondragstart={(e) => {
if ($viewMode === 'quickaccess') {
qaDragFrom = noteIndex;
e.dataTransfer!.setData('text/plain', note.path);
e.dataTransfer!.effectAllowed = 'move';
return;
}
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) { if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n')); e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else { } else {
@@ -715,28 +535,9 @@
} }
e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.effectAllowed = 'move';
}} }}
ondragover={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex;
}
}}
ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null;
}}
ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault();
handleQaDrop(noteIndex);
}
}}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
> >
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row">
<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">
@@ -745,13 +546,10 @@
{/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" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div 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">
<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"/>
@@ -761,10 +559,6 @@
</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">
@@ -778,7 +572,6 @@
</button> </button>
{/if} {/if}
{/each} {/each}
<div style="height: {bottomPad}px"></div>
</div> </div>
</div> </div>
@@ -793,50 +586,7 @@
<button class="danger" onclick={() => { contextMenu = null; handleBatchPermanentDelete(); }}> <button class="danger" onclick={() => { contextMenu = null; handleBatchPermanentDelete(); }}>
Delete {selectedPaths.size} permanently Delete {selectedPaths.size} permanently
</button> </button>
{:else if batchTagEdit}
<button class="move-back" 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">
<polyline points="15 18 9 12 15 6"/>
</svg>
Tags for {selectedPaths.size} notes
</button>
<div class="context-sep"></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>
{:else} {:else}
<button onclick={() => openBatchTagEdit()}>
<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="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
Tags...
</button>
<button onclick={() => { contextMenu = null; batchMovePicker = true; }}> <button onclick={() => { contextMenu = null; batchMovePicker = true; }}>
<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="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"/>
@@ -850,43 +600,6 @@
{:else if $viewMode === 'trash'} {:else if $viewMode === 'trash'}
<button onclick={() => handleRestore(contextMenu!.note)}>Restore</button> <button onclick={() => handleRestore(contextMenu!.note)}>Restore</button>
<button class="danger" onclick={() => handlePermanentDelete(contextMenu!.note)}>Delete Permanently</button> <button class="danger" onclick={() => handlePermanentDelete(contextMenu!.note)}>Delete Permanently</button>
{:else if tagEditNote}
<button class="move-back" onclick={() => tagEditNote = null}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
Tags
</button>
<div class="context-sep"></div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
placeholder="Add tag..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToNote(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); tagEditNote = null; }
}}
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={() => removeTagFromNote(tag)} title="Remove tag">
<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 tags</div>
{/if}
</div>
{:else if movePickerNote} {:else if movePickerNote}
<button class="move-back" onclick={() => movePickerNote = null}> <button class="move-back" onclick={() => movePickerNote = null}>
<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">
@@ -916,12 +629,6 @@
</svg> </svg>
{contextMenu.note.meta.pinned ? 'Unpin Note' : 'Pin Note'} {contextMenu.note.meta.pinned ? 'Unpin Note' : 'Pin Note'}
</button> </button>
<button onclick={() => openTagEdit(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">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
Tags...
</button>
{#if $quickAccessPaths.includes(contextMenu.note.relative_path)} {#if $quickAccessPaths.includes(contextMenu.note.relative_path)}
<button onclick={() => handleRemoveQuickAccess(contextMenu!.note)}> <button onclick={() => handleRemoveQuickAccess(contextMenu!.note)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -937,12 +644,6 @@
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"/>
@@ -1060,8 +761,8 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s;
margin-bottom: 1px; margin-bottom: 1px;
contain: content;
} }
.note-item:hover { .note-item:hover {
@@ -1263,26 +964,6 @@
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);
@@ -1404,62 +1085,6 @@
border-radius: 2px; border-radius: 2px;
} }
.tag-edit-section {
padding: 4px 0;
}
.tag-edit-input-row {
padding: 2px 8px 4px;
}
.tag-edit-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.tag-edit-input:focus {
border-color: var(--accent);
}
.tag-edit-list {
max-height: 160px;
overflow-y: auto;
}
.tag-edit-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 12px;
font-size: 12px;
}
.tag-edit-name {
color: var(--text-secondary);
}
.tag-edit-remove {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 2px;
border-radius: 3px;
display: flex;
align-items: center;
}
.tag-edit-remove:hover {
color: var(--danger, #e53e3e);
background: color-mix(in srgb, var(--danger, #e53e3e) 10%, transparent);
}
.sort-menu { .sort-menu {
position: fixed; position: fixed;
background: var(--bg-primary); background: var(--bg-primary);
@@ -1508,140 +1133,4 @@
color: var(--accent); color: var(--accent);
font-size: 14px; font-size: 14px;
} }
.note-item.qa-drag-above {
border-top: 2px solid var(--accent);
}
.note-item.qa-drag-below {
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-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;
}
.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;
}
.note-list.mobile .selection-count {
font-size: 13px;
}
.note-list.mobile .selection-action {
padding: 6px 14px;
font-size: 13px;
min-height: 36px;
}
.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;
}
</style> </style>
-326
View File
@@ -1,326 +0,0 @@
<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>
+2 -14
View File
@@ -2,35 +2,23 @@
let { onResize }: { onResize: (delta: number) => void } = $props(); let { onResize }: { onResize: (delta: number) => void } = $props();
let active = $state(false); let active = $state(false);
let startX = 0; let startX = 0;
let rafId = 0;
let pendingDelta = 0;
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
active = true; active = true;
startX = e.clientX; startX = e.clientX;
document.body.classList.add('resizing');
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
pendingDelta += e.clientX - startX; const delta = e.clientX - startX;
startX = e.clientX; startX = e.clientX;
if (!rafId) { onResize(delta);
rafId = requestAnimationFrame(() => {
onResize(pendingDelta);
pendingDelta = 0;
rafId = 0;
});
}
} }
function onMouseUp() { function onMouseUp() {
active = false; active = false;
document.body.classList.remove('resizing');
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
if (pendingDelta) { onResize(pendingDelta); pendingDelta = 0; }
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mouseup', onMouseUp);
} }
+49 -169
View File
@@ -1,6 +1,6 @@
<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, installType, settingsTab } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, 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 { check as checkUpdate } from '@tauri-apps/plugin-updater';
@@ -41,14 +41,6 @@
} }
}); });
// Pre-populate updater object from global store so Download & Install works without manual check
$effect(() => {
const obj = $globalUpdateObj;
if (obj && !updateObj) {
updateObj = obj;
}
});
async function handleCheckUpdate() { async function handleCheckUpdate() {
updateChecking = true; updateChecking = true;
updateMessage = null; updateMessage = null;
@@ -57,7 +49,6 @@
const update = await checkUpdate(); const update = await checkUpdate();
if (update) { if (update) {
updateObj = update; updateObj = update;
$globalUpdateObj = update;
updateAvailable = { version: update.version, body: update.body, date: update.date }; updateAvailable = { version: update.version, body: update.body, date: update.date };
globalUpdateAvailable.set({ version: update.version, body: update.body }); globalUpdateAvailable.set({ version: update.version, body: update.body });
updateMessage = { type: 'info', text: `Version ${update.version} is available!` }; updateMessage = { type: 'info', text: `Version ${update.version} is available!` };
@@ -204,20 +195,18 @@
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 _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434'); let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-5-20250929');
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-6', label: 'Claude Sonnet 4.6', desc: 'Balanced' }, { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', 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' },
]; ];
@@ -230,15 +219,13 @@
let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels); let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels);
async function saveAiSettings() { async function saveAiSettings() {
const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null; await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || 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,
}; };
@@ -297,17 +284,9 @@
{ 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);
@@ -320,7 +299,6 @@
let pdfHeight = $state($appConfig?.pdf_height ?? 600); let pdfHeight = $state($appConfig?.pdf_height ?? 600);
let titleMode = $state($appConfig?.title_mode ?? 'input'); let titleMode = $state($appConfig?.title_mode ?? 'input');
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false); let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let showLineNumbers = $state($appConfig?.show_line_numbers ?? false);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false); let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false); let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false); let closeToTray = $state($appConfig?.close_to_tray ?? false);
@@ -373,13 +351,12 @@
$appConfig.pdf_height = pdfHeight; $appConfig.pdf_height = pdfHeight;
$appConfig.title_mode = titleMode; $appConfig.title_mode = titleMode;
$appConfig.hide_title_in_body = hideTitleInBody; $appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.show_line_numbers = showLineNumbers;
$appConfig.default_view_mode = defaultViewMode; $appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon; $appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray; $appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $appConfig.enable_wiki_links = enableWikiLinks;
} }
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks) setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
.catch((e) => console.error('Failed to save general settings:', e)); .catch((e) => console.error('Failed to save general settings:', e));
} }
@@ -434,17 +411,6 @@
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
@@ -477,11 +443,6 @@
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;
@@ -640,7 +601,13 @@
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Title</h3> <h3>Title</h3>
<label class="setting-toggle"> <div class="setting-options">
<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>
@@ -649,15 +616,6 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Show line numbers</span>
<span class="setting-desc">Display line numbers in the markdown source editor</span>
</span>
<button class="toggle-switch" class:on={showLineNumbers} onclick={() => { showLineNumbers = !showLineNumbers; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
<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>
@@ -776,22 +734,6 @@
</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">
@@ -985,86 +927,52 @@
<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 === '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-5-20250929'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button> <button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
</div> </div>
</div> </div>
{#if aiProvider} {#if aiProvider}
{#if aiProvider === 'ollama'} <div class="settings-section">
<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> <h3>API Key</h3>
{:else} <div class="ai-key-row">
<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="text" type={aiShowKey ? 'text' : 'password'}
class="ai-key-input" class="ai-key-input"
placeholder="http://localhost:11434" placeholder={aiProvider === 'openai' ? 'sk-...' : 'sk-ant-...'}
value={_ollamaBaseUrl} value={aiApiKey}
oninput={(e) => { _ollamaBaseUrl = (e.target as HTMLInputElement).value; }} oninput={(e) => { const v = (e.target as HTMLInputElement).value; if (aiProvider === 'openai') _openaiKey = v; else _anthropicKey = v; }}
onblur={saveAiSettings} onblur={saveAiSettings}
/> />
<p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p> <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> </div>
{:else} {#if aiProvider === 'openai'}
<div class="settings-section"> <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>
<h3>API Key</h3> {:else}
<div class="ai-key-row"> <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>
<input {/if}
type={aiShowKey ? 'text' : 'password'} </div>
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>
{#if aiProvider === 'ollama'} <div class="ai-model-options">
<input {#each aiModels as m}
type="text" <button
class="ai-key-input" class="ai-model-btn"
placeholder="gemma3:4b" class:active={aiModel === m.value}
value={aiModel} onclick={() => { aiModel = m.value; saveAiSettings(); }}
oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }} >
onblur={saveAiSettings} <span class="ai-model-name">{m.label}</span>
/> <span class="ai-model-desc">{m.desc}</span>
<p class="setting-hint">Enter the model name as shown by <code>ollama list</code></p> </button>
{:else} {/each}
<div class="ai-model-options"> </div>
{#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">
@@ -1082,7 +990,7 @@
<div class="settings-section"> <div class="settings-section">
<h3>Connection</h3> <h3>Connection</h3>
<button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || (aiProvider !== 'ollama' && !aiApiKey)}> <button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || !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...
@@ -1144,7 +1052,7 @@
<div class="update-notes">{updateAvailable.body}</div> <div class="update-notes">{updateAvailable.body}</div>
{/if} {/if}
</div> </div>
{#if $installType === 'appimage' || $installType === 'windows' || $installType === 'macos'} {#if $installType === 'appimage' || $installType === 'windows'}
<button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={updateDownloading}> <button class="update-install-btn" onclick={handleDownloadAndInstall} disabled={updateDownloading}>
{#if updateDownloading} {#if updateDownloading}
<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>
@@ -1161,16 +1069,6 @@
<div class="update-progress-fill" style="width: {updateProgress}%"></div> <div class="update-progress-fill" style="width: {updateProgress}%"></div>
</div> </div>
{/if} {/if}
{:else if $installType === 'deb'}
<div class="update-apt-info">
<p>Update via your package manager:</p>
<code>sudo apt update && sudo apt upgrade helix-notes</code>
</div>
{:else if $installType === 'aur'}
<div class="update-apt-info">
<p>Update via your AUR helper:</p>
<code>yay -Syu helixnotes</code>
</div>
{: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">
@@ -1219,8 +1117,7 @@
border-radius: 16px; border-radius: 16px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
width: 620px; width: 620px;
height: 80vh; max-height: 80vh;
max-height: 700px;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -2048,23 +1945,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.update-apt-info {
margin-top: 8px;
}
.update-apt-info p {
margin: 0 0 6px 0;
font-size: 13px;
color: var(--text-secondary);
}
.update-apt-info code {
display: block;
padding: 8px 12px;
background: var(--bg-secondary);
border-radius: 6px;
font-size: 12px;
user-select: all;
}
.update-progress-bar { .update-progress-bar {
margin-top: 10px; margin-top: 10px;
height: 6px; height: 6px;
+29 -412
View File
@@ -17,7 +17,7 @@
quickAccessPaths, quickAccessPaths,
collapsedNotebooks collapsedNotebooks
} from '$lib/stores/app'; } from '$lib/stores/app';
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api'; import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
@@ -27,19 +27,13 @@
onViewChanged?: () => void; onViewChanged?: () => void;
} = $props(); } = $props();
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('');
let newNotebookName = $state(''); let newNotebookName = $state('');
let showNewNotebook = $state(false); let showNewNotebook = $state(false);
let dropTargetPath = $state<string | null>(null); let dropTargetPath = $state<string | null>(null);
let draggedNotebookPath = $state<string | null>(null);
let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null); let contextMenu = $state<{ x: number; y: number; notebook: NotebookEntry } | null>(null);
let trashContextMenu = $state<{ x: number; y: number } | null>(null); let trashContextMenu = $state<{ x: number; y: number } | null>(null);
let tagsCollapsed = $state(true);
let deleteConfirm = $state<NotebookEntry | null>(null);
function toggleCollapse(path: string, e: MouseEvent) { function toggleCollapse(path: string, e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
if ($collapsedNotebooks.includes(path)) { if ($collapsedNotebooks.includes(path)) {
@@ -69,11 +63,6 @@
} }
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;
@@ -101,33 +90,18 @@
onViewChanged(); onViewChanged();
} }
let newNotebookParent = $state<NotebookEntry | null>(null);
async function handleCreateNotebook() { async function handleCreateNotebook() {
if (!newNotebookName.trim()) return; if (!newNotebookName.trim()) return;
try { try {
const parentRel = newNotebookParent?.relative_path ?? null; await createNotebook(null, newNotebookName.trim());
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;
@@ -142,22 +116,8 @@
} }
} }
function countNotesRecursive(nb: NotebookEntry): number { async function handleDelete(nb: NotebookEntry) {
return nb.note_count + nb.children.reduce((sum, c) => sum + countNotesRecursive(c), 0);
}
function handleDelete(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
const total = countNotesRecursive(nb);
if (total > 0) {
deleteConfirm = nb;
} else {
confirmDelete(nb);
}
}
async function confirmDelete(nb: NotebookEntry) {
deleteConfirm = null;
try { try {
await deleteNotebook(nb.path); await deleteNotebook(nb.path);
if ($activeNotebook?.path === nb.path) { if ($activeNotebook?.path === nb.path) {
@@ -190,43 +150,6 @@
} }
} }
async function handleNotebookDrop(e: DragEvent, destPath: string) {
e.preventDefault();
dropTargetPath = null;
const srcPath = e.dataTransfer?.getData('text/plain');
if (!srcPath) return;
draggedNotebookPath = null;
// Don't move onto self or descendant
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return;
// Don't move if already in that parent
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
if (parentDir === destPath) return;
try {
const oldName = srcPath.split('/').pop() || '';
const newBasePath = destPath + '/' + oldName;
await moveNotebook(srcPath, destPath);
// Update collapsedNotebooks paths
$collapsedNotebooks = $collapsedNotebooks.map(p => {
if (p === srcPath) return newBasePath;
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length);
return p;
});
// Update active notebook/note if inside the moved notebook
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
selectAllNotes();
}
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
$activeNotePath = newNotePath;
$activeNote = await readNote(newNotePath);
}
$notebookIcons = await getNotebookIcons();
await refresh();
} catch (e) {
console.error('Failed to move notebook:', e);
}
}
function startRename(nb: NotebookEntry) { function startRename(nb: NotebookEntry) {
contextMenu = null; contextMenu = null;
editingNotebook = nb.path; editingNotebook = nb.path;
@@ -309,7 +232,7 @@
<svelte:window onclick={handleWindowClick} /> <svelte:window onclick={handleWindowClick} />
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}> <aside class="sidebar" class:collapsed={$sidebarCollapsed}>
<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">
@@ -321,7 +244,7 @@
</svg> </svg>
</button> </button>
{#if !$sidebarCollapsed} {#if !$sidebarCollapsed}
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}> <button class="icon-btn" onclick={() => ($showSearch = true)} title="Search (Ctrl+F)">
<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">
<circle cx="11" cy="11" r="8" /> <circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
@@ -369,34 +292,9 @@
</nav> </nav>
<div class="section"> <div class="section">
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="section-header">
<div
class="section-header"
class:drop-target={dropTargetPath === '__root__'}
ondragover={(e) => {
if (draggedNotebookPath) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
dropTargetPath = '__root__';
}
}}
ondragleave={() => { if (dropTargetPath === '__root__') dropTargetPath = null; }}
ondrop={(e) => {
if (draggedNotebookPath) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) handleNotebookDrop(e, vaultRoot);
}
}}
>
<span class="section-title">Notebooks</span> <span class="section-title">Notebooks</span>
<button class="icon-btn-sm" onclick={() => { <button class="icon-btn-sm" onclick={() => (showNewNotebook = !showNewNotebook)} title="New notebook">
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>
@@ -405,16 +303,13 @@
{#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={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'} placeholder="Notebook name..."
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') handleCreateNotebook(); if (e.key === 'Enter') handleCreateNotebook();
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; } if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; }
}} }}
/> />
</div> </div>
@@ -428,26 +323,23 @@
</div> </div>
{#if $tags.length > 0} {#if $tags.length > 0}
<div class="tags-section"> <div class="section">
<button class="section-header" onclick={() => tagsCollapsed = !tagsCollapsed}> <div class="section-header">
<span class="section-title">Tags</span> <span class="section-title">Tags</span>
<span class="tag-count" style="margin-left: auto">{$tags.length}</span> </div>
</button> <div class="tag-list">
{#if !tagsCollapsed} {#each $tags as [tag, count]}
<div class="tag-list"> <button
{#each $tags as [tag, count]} class="tag-item"
<button class:active={$viewMode === 'tag' && $activeTag === tag}
class="tag-item" onclick={() => selectTag(tag)}
class:active={$viewMode === 'tag' && $activeTag === tag} >
onclick={() => selectTag(tag)} <span class="tag-hash">#</span>
> <span class="tag-name">{tag}</span>
<span class="tag-hash">#</span> <span class="tag-count">{count}</span>
<span class="tag-name">{tag}</span> </button>
<span class="tag-count">{count}</span> {/each}
</button> </div>
{/each}
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -475,10 +367,6 @@
{#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
@@ -512,21 +400,6 @@
</div> </div>
{/if} {/if}
{#if deleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm-overlay" onclick={() => deleteConfirm = null}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="delete-confirm" onclick={(e) => e.stopPropagation()}>
<h4>Delete "{deleteConfirm.name}"?</h4>
<p>This notebook contains {countNotesRecursive(deleteConfirm)} note{countNotesRecursive(deleteConfirm) === 1 ? '' : 's'} that will be permanently deleted.</p>
<div class="delete-confirm-actions">
<button class="delete-confirm-cancel" onclick={() => deleteConfirm = null}>Cancel</button>
<button class="delete-confirm-btn" onclick={() => confirmDelete(deleteConfirm!)}>Delete</button>
</div>
</div>
</div>
{/if}
{#snippet notebookItem(nb: NotebookEntry, depth: number)} {#snippet notebookItem(nb: NotebookEntry, depth: number)}
{@const hasChildren = nb.children.length > 0} {@const hasChildren = nb.children.length > 0}
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)} {@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
@@ -550,34 +423,11 @@
class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path} class:active={$viewMode === 'notebook' && $activeNotebook?.path === nb.path}
class:drop-target={dropTargetPath === nb.path} class:drop-target={dropTargetPath === nb.path}
style="padding-left: {8 + depth * 16}px" style="padding-left: {8 + depth * 16}px"
draggable="true"
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
oncontextmenu={(e) => onContextMenu(e, nb)} oncontextmenu={(e) => onContextMenu(e, nb)}
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }} ondragover={(e) => { e.preventDefault(); e.dataTransfer!.dropEffect = 'move'; dropTargetPath = nb.path; }}
ondragend={() => { draggedNotebookPath = null; }}
ondragover={(e) => {
e.preventDefault();
if (draggedNotebookPath) {
// Prevent drop on self or descendant
if (nb.path === draggedNotebookPath || nb.path.startsWith(draggedNotebookPath + '/')) {
e.dataTransfer!.dropEffect = 'none';
return;
}
// Prevent no-op (already in this parent)
const parentDir = draggedNotebookPath.substring(0, draggedNotebookPath.lastIndexOf('/'));
if (parentDir === nb.path) { e.dataTransfer!.dropEffect = 'none'; return; }
}
e.dataTransfer!.dropEffect = 'move';
dropTargetPath = nb.path;
}}
ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }} ondragleave={() => { if (dropTargetPath === nb.path) dropTargetPath = null; }}
ondrop={(e) => { ondrop={(e) => handleNoteDrop(e, nb)}
if (draggedNotebookPath) {
handleNotebookDrop(e, nb.path);
} else {
handleNoteDrop(e, nb);
}
}}
> >
{#if hasChildren} {#if hasChildren}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -669,26 +519,11 @@
padding: 4px 0; padding: 4px 0;
} }
.tags-section {
flex-shrink: 0;
border-top: 1px solid var(--border-light);
padding: 4px 0;
max-height: 30%;
overflow-y: auto;
}
.section-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 8px 12px 4px; padding: 8px 12px 4px;
width: 100%;
background: none;
border: none;
cursor: pointer;
color: inherit;
font: inherit;
text-align: left;
} }
.section-title { .section-title {
@@ -734,35 +569,9 @@
.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-parent { .new-notebook-input input, .rename-input {
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);
@@ -801,8 +610,7 @@
color: var(--text-accent); color: var(--text-accent);
} }
.notebook-item.drop-target, .notebook-item.drop-target {
.section-header.drop-target {
background: color-mix(in srgb, var(--accent) 20%, transparent); background: color-mix(in srgb, var(--accent) 20%, transparent);
outline: 2px dashed var(--accent); outline: 2px dashed var(--accent);
outline-offset: -2px; outline-offset: -2px;
@@ -951,195 +759,4 @@
.context-menu button.danger:hover { .context-menu button.danger:hover {
background: color-mix(in srgb, var(--danger) 10%, transparent); background: color-mix(in srgb, var(--danger) 10%, transparent);
} }
.delete-confirm-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.delete-confirm {
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 20px 24px;
max-width: 340px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.delete-confirm h4 {
margin: 0 0 8px;
font-size: 14px;
color: var(--text-primary);
}
.delete-confirm p {
margin: 0 0 16px;
font-size: 12.5px;
color: var(--text-secondary);
line-height: 1.4;
}
.delete-confirm-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.delete-confirm-cancel {
padding: 6px 14px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.delete-confirm-cancel:hover {
background: var(--bg-hover);
}
.delete-confirm-btn {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: var(--danger, #e53e3e);
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.delete-confirm-btn:hover {
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-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>
+5 -40
View File
@@ -1,15 +1,12 @@
<script lang="ts"> <script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab } from '$lib/stores/app'; import { vaultReady, focusMode, updateAvailable, showSettings, settingsTab } from '$lib/stores/app';
let { onNewNote = () => {}, onDailyNote = () => {} }: { let { onNewNote = () => {} }: {
onNewNote?: () => void; onNewNote?: () => void;
onDailyNote?: () => void;
} = $props(); } = $props();
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const modKey = isMac ? '⌘' : 'Ctrl';
let maximized = $state(false); let maximized = $state(false);
async function checkMaximized() { async function checkMaximized() {
@@ -62,7 +59,7 @@
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="titlebar" class:macos={isMac} onmousedown={handleMouseDown}> <div class="titlebar" onmousedown={handleMouseDown}>
<div class="titlebar-brand"> <div class="titlebar-brand">
<svg width="18" height="18" viewBox="0 0 48 48" fill="none"> <svg width="18" height="18" viewBox="0 0 48 48" fill="none">
<rect width="48" height="48" rx="12" fill="var(--accent)" /> <rect width="48" height="48" rx="12" fill="var(--accent)" />
@@ -92,34 +89,13 @@
<path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/> <path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/>
</svg> </svg>
</button> </button>
<button class="switch-vault-btn" class:active={$readOnly} onclick={() => ($readOnly = !$readOnly)} title={$readOnly ? 'Switch to Edit Mode' : 'Switch to View Mode'}> <button class="new-note-btn" onclick={onNewNote} title="New Note (Ctrl+N)">
<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>
<button class="switch-vault-btn" onclick={onDailyNote} title="Daily Note">
<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="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="new-note-btn" onclick={onNewNote} title={`New Note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> <path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg> </svg>
New Note New Note
</button> </button>
</div> </div>
{#if !isMac}
<div class="titlebar-controls"> <div class="titlebar-controls">
<button class="titlebar-btn" onclick={minimize} title="Minimize"> <button class="titlebar-btn" onclick={minimize} title="Minimize">
<svg width="10" height="10" viewBox="0 0 10 10"> <svg width="10" height="10" viewBox="0 0 10 10">
@@ -145,7 +121,6 @@
</svg> </svg>
</button> </button>
</div> </div>
{/if}
</div> </div>
<style> <style>
@@ -221,11 +196,6 @@
color: var(--text-primary); color: var(--text-primary);
} }
.switch-vault-btn.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.new-note-btn { .new-note-btn {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -233,7 +203,7 @@
padding: 4px 12px 4px 10px; padding: 4px 12px 4px 10px;
border: none; border: none;
border-radius: 7px; border-radius: 7px;
background: color-mix(in srgb, var(--accent) 85%, black); background: var(--accent);
color: white; color: white;
font-size: 11.5px; font-size: 11.5px;
font-weight: 600; font-weight: 600;
@@ -282,9 +252,4 @@
background: #e81123; background: #e81123;
color: white; color: white;
} }
/* macOS: add left padding for native traffic light buttons */
.titlebar.macos .titlebar-brand {
padding-left: 78px;
}
</style> </style>
+8 -43
View File
@@ -6,22 +6,15 @@
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('');
async function pickFolder() { async function pickFolder() {
if (isMobile) { const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
// Use shared Documents folder for Syncthing/Nextcloud compatibility if (selected) {
const vaultPath = '/storage/emulated/0/Documents/HelixNotes'; await openSelectedVault(selected as string);
await openSelectedVault(vaultPath);
} else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
if (selected) {
await openSelectedVault(selected as string);
}
} }
} }
@@ -42,8 +35,7 @@
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="vault-picker" class:mobile={isMobile} onmousedown={(e) => { if (!isMobile && !(e.target as HTMLElement).closest('button, .picker-card')) appWindow.startDragging(); }}> <div class="vault-picker" onmousedown={(e) => { if (!(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">
@@ -52,7 +44,6 @@
</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">
@@ -66,12 +57,8 @@
</svg> </svg>
</div> </div>
<h1>HelixNotes</h1> <h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p> <p class="subtitle">Local-first markdown notes</p>
{#if isMobile} <p class="description">Your notes are stored as standard Markdown (.md) files. Pick any folder — existing .md files will be recognized automatically.</p>
<p class="description">Notes are stored in Documents/HelixNotes as Markdown files. Sync with Nextcloud, Syncthing, or any file sync app.</p>
{: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>
@@ -80,20 +67,18 @@
<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}
</button> </button>
{#if !isMobile && $appConfig?.active_vault} {#if $appConfig?.active_vault}
<button class="btn-back" onclick={() => ($vaultReady = true)}> <button class="btn-back" onclick={() => ($vaultReady = true)}>
Back Back
</button> </button>
{/if} {/if}
{#if !isMobile && recentVaults.length > 0} {#if recentVaults.length > 0}
<div class="recent"> <div class="recent">
<span class="recent-label">Recent</span> <span class="recent-label">Recent</span>
{#each recentVaults as vault} {#each recentVaults as vault}
@@ -275,24 +260,4 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
/* 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;
}
</style> </style>
+22 -36
View File
@@ -38,16 +38,10 @@ 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);
export const focusMode = writable(false); export const focusMode = writable(false);
export const readOnly = writable(false);
// Theme // Theme
export const theme = writable<string>("system"); export const theme = writable<string>("system");
@@ -57,7 +51,6 @@ export const updateAvailable = writable<{
version: string; version: string;
body?: string; body?: string;
} | null>(null); } | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native"); export const installType = writable<string>("native");
export async function checkForUpdate() { export async function checkForUpdate() {
@@ -66,7 +59,6 @@ export async function checkForUpdate() {
const update = await check(); const update = await check();
if (update) { if (update) {
updateAvailable.set({ version: update.version, body: update.body }); updateAvailable.set({ version: update.version, body: update.body });
updateObj.set(update);
} }
} catch { } catch {
// Silent fail — don't disrupt app startup // Silent fail — don't disrupt app startup
@@ -74,36 +66,30 @@ export async function checkForUpdate() {
} }
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived([notes, sortMode], ([$notes, $sortMode]) => {
[notes, sortMode, viewMode], const pinned = $notes.filter((n) => n.meta.pinned);
([$notes, $sortMode, $viewMode]) => { const unpinned = $notes.filter((n) => !n.meta.pinned);
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
const pinned = $notes.filter((n) => n.meta.pinned); const sortFn = (a: NoteEntry, b: NoteEntry) => {
const unpinned = $notes.filter((n) => !n.meta.pinned); switch ($sortMode) {
case "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
return (
new Date(b.meta.created).getTime() -
new Date(a.meta.created).getTime()
);
case "modified":
default:
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
};
const sortFn = (a: NoteEntry, b: NoteEntry) => { return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)];
switch ($sortMode) { });
case "title":
return a.meta.title.localeCompare(b.meta.title);
case "created":
return (
new Date(b.meta.created).getTime() -
new Date(a.meta.created).getTime()
);
case "modified":
default:
return (
new Date(b.meta.modified).getTime() -
new Date(a.meta.modified).getTime()
);
}
};
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)];
},
);
export const vaultState = derived( export const vaultState = derived(
[ [
-2
View File
@@ -41,7 +41,6 @@ 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;
@@ -60,7 +59,6 @@ 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
@@ -1,42 +0,0 @@
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);
});
}
+4 -8
View File
@@ -8,8 +8,6 @@
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;
@@ -33,12 +31,10 @@
return resolved.join('/'); return resolved.join('/');
} }
// Detect install type and check for updates on startup (skip on mobile — updates via app store) // Detect install type and check for updates on startup
onMount(() => { onMount(() => {
if (!isMobile) { getInstallType().then(t => installType.set(t)).catch(() => {});
getInstallType().then(t => installType.set(t)).catch(() => {}); checkForUpdate();
checkForUpdate();
}
}); });
// Intercept all link clicks in capture phase to prevent webview navigation // Intercept all link clicks in capture phase to prevent webview navigation
@@ -78,7 +74,7 @@
}); });
</script> </script>
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} /> <svelte:document oncontextmenu={(e) => e.preventDefault()} />
<svelte:head> <svelte:head>
<title>HelixNotes</title> <title>HelixNotes</title>
+1 -18
View File
@@ -4,19 +4,10 @@
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;
@@ -34,9 +25,6 @@
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',
@@ -81,10 +69,7 @@
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
try { try {
// Note windows don't re-open the vault (already open in main process) await openVault(config.active_vault);
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
@@ -101,8 +86,6 @@
<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}