Compare commits

..
12 Commits
21 changed files with 1270 additions and 224 deletions
+20 -26
View File
@@ -5,7 +5,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.1.8) ## Download (v1.2.1)
### Linux ### Linux
@@ -23,19 +23,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.1.8/HelixNotes_1.1.8_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.8/HelixNotes_1.1.8_amd64.deb) Ubuntu 22.04+ [Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.8/HelixNotes_1.1.8_x64-setup.exe) Windows 10/11 [Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.8/HelixNotes_1.1.8_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta) [Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.1/HelixNotes_1.2.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://download.helixnotes.com/releases/v1.2.1/HelixNotes_1.2.1_android.apk)
--- ---
@@ -43,26 +47,16 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features ## Features
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting - Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Wiki-links** — link notes with `[[Note Title]]` syntax - `[[Wiki-links]]` and graph view
- **Graph view** — visualize connections between your notes - Full-text search (Tantivy)
- **Outline panel** — heading navigation for long notes - Outline panel, daily notes, tags, drag-and-drop
- **Full-text search** powered by Tantivy - Math (KaTeX), PDF preview, Obsidian import
- **Math support** — KaTeX rendering for inline and block equations - AI writing tools (Ollama / Anthropic / OpenAI)
- **Daily notes** — one-click button to create or open today's note - Version history with diffs, automatic backups
- **AI writing tools** — improve, summarize, translate, and more (Ollama / Anthropic / OpenAI) - Multi-window, file associations, focus mode, view mode
- **Version history** — per-note snapshots with diff view - Themes, accent colors, fonts
- **Backups** — automatic zip-based vault backups - Local files, no cloud
- **PDF preview** — inline rendering of embedded PDFs
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Multi-window** — open notes in separate windows (right-click → "Open in New Window")
- **File associations** — open .md files from your file manager directly in HelixNotes
- **Themes** — light/dark mode with customizable accent colors, fonts, and line height
- **Focus mode** — distraction-free writing
- **View mode** — read-only toggle for distraction-free reading
- **Local** — everything stays on your machine
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html) Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -70,7 +64,7 @@ Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3 - **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher) - **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
- **Platforms**: Linux (AppImage), Windows, macOS - **Platforms**: Linux (AppImage), Windows, macOS, Android
## Building from Source ## Building from Source
@@ -0,0 +1,3 @@
- Internal note links ([[wiki-links]] open the linked note)
- Mobile UI improvements
- Window state persistence (desktop)
@@ -0,0 +1,12 @@
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
Features:
- Markdown editor with toolbar, slash commands, and code highlighting
- [[Wiki-links]] and graph view
- Full-text search
- Outline panel, daily notes, tags
- Math (KaTeX) and PDF preview
- AI writing tools (Ollama, Anthropic, OpenAI)
- Version history with diffs
- Obsidian import
- Themes, accent colors, fonts
@@ -0,0 +1 @@
Local markdown note-taking app. No cloud, no lock-in.
@@ -0,0 +1 @@
HelixNotes
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.1.9", "version": "1.2.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+84 -4
View File
@@ -633,6 +633,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"pure-rust-locales",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link 0.2.1", "windows-link 0.2.1",
@@ -647,6 +648,12 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@@ -1603,6 +1610,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]] [[package]]
name = "gio" name = "gio"
version = "0.18.4" version = "0.18.4"
@@ -1833,13 +1850,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.1.9" version = "1.2.2"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
"dirs", "dirs",
"futures", "futures",
"gray_matter", "gray_matter",
"image",
"log", "log",
"notify", "notify",
"png 0.17.16", "png 0.17.16",
@@ -1850,6 +1868,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sys-locale",
"tantivy", "tantivy",
"tauri", "tauri",
"tauri-build", "tauri-build",
@@ -1859,6 +1878,7 @@ dependencies = [
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio", "tokio",
"uuid", "uuid",
"walkdir", "walkdir",
@@ -2157,10 +2177,25 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png 0.18.1", "png 0.18.1",
"tiff", "tiff",
"zune-core 0.5.1",
"zune-jpeg 0.5.12",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
] ]
[[package]] [[package]]
@@ -3109,7 +3144,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.45.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3556,6 +3591,12 @@ dependencies = [
"syn 1.0.109", "syn 1.0.109",
] ]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.27" version = "0.1.27"
@@ -4737,6 +4778,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
@@ -5230,6 +5280,21 @@ dependencies = [
"zip 4.6.1", "zip 4.6.1",
] ]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.0",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-runtime" name = "tauri-runtime"
version = "2.10.0" version = "2.10.0"
@@ -5406,7 +5471,7 @@ dependencies = [
"half", "half",
"quick-error", "quick-error",
"weezl", "weezl",
"zune-jpeg", "zune-jpeg 0.4.21",
] ]
[[package]] [[package]]
@@ -7225,13 +7290,28 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]] [[package]]
name = "zune-jpeg" name = "zune-jpeg"
version = "0.4.21" version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [ dependencies = [
"zune-core", "zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
dependencies = [
"zune-core 0.5.1",
] ]
[[package]] [[package]]
+5 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.9" version = "1.2.2"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
@@ -26,7 +26,8 @@ serde_yaml = "0.9"
log = "0.4" log = "0.4"
notify = "8" notify = "8"
walkdir = "2" walkdir = "2"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sys-locale = "0.3"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
tantivy = "0.22" tantivy = "0.22"
gray_matter = "0.2" gray_matter = "0.2"
@@ -41,5 +42,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
[target.'cfg(not(target_os = "android"))'.dependencies] [target.'cfg(not(target_os = "android"))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] } arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17" png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+2 -1
View File
@@ -5,6 +5,7 @@
"platforms": ["linux", "macOS", "windows"], "platforms": ["linux", "macOS", "windows"],
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"updater:default" "updater:default",
"window-state:default"
] ]
} }
+149
View File
@@ -400,6 +400,128 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
Ok(entries) Ok(entries)
} }
// ── Graph ──
#[tauri::command]
pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphData, String> {
use std::collections::{HashMap, HashSet};
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing)
let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.contains(".sync-conflict-") { continue; }
}
// Deduplicate by canonical path (handles symlinks)
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let canonical_str = canonical.to_string_lossy().to_string();
if !seen_paths.insert(canonical_str) { continue; }
let raw = std::fs::read_to_string(path).unwrap_or_default();
// Fast title extraction: scan for "title: " line in frontmatter without full YAML parse
let title = extract_title_fast(&raw).unwrap_or_else(|| {
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
});
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx);
graph_nodes.push(crate::types::GraphNode {
title,
path: path_str,
});
contents.push(raw);
}
// Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new();
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes();
let len = bytes.len();
let mut i = 0;
while i + 1 < len {
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
i += 2;
let start = i;
while i + 1 < len && !(bytes[i] == b']' && bytes[i + 1] == b']') {
i += 1;
}
if i + 1 < len {
let link_raw = &body[start..i];
// Strip |alias, #heading, ^block
let link = link_raw.split('|').next().unwrap_or(link_raw);
let link = link.split('#').next().unwrap_or(link);
let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) {
if target_idx != source_idx {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
if edge_set.insert(key) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx });
}
}
}
i += 2;
}
} else {
i += 1;
}
}
}
Ok(crate::types::GraphData { nodes: graph_nodes, edges })
}
/// Fast title extraction from frontmatter without full YAML parsing.
/// Scans for `title: ...` line within `---` fences.
fn extract_title_fast(raw: &str) -> Option<String> {
let trimmed = raw.trim_start();
if !trimmed.starts_with("---") { return None; }
// Find the closing ---
let after_open = &trimmed[3..];
let end = after_open.find("\n---")?;
let frontmatter = &after_open[..end];
for line in frontmatter.lines() {
let line = line.trim();
if line.starts_with("title:") {
let val = line[6..].trim();
// Strip surrounding quotes
if (val.starts_with('"') && val.ends_with('"')) || (val.starts_with('\'') && val.ends_with('\'')) {
return Some(val[1..val.len()-1].to_string());
}
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
// ── Search ── // ── Search ──
#[tauri::command] #[tauri::command]
@@ -509,6 +631,33 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string()) Err("Clipboard image reading not supported on Android".to_string())
} }
/// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(target_os = "android")]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ── // ── Attachments ──
#[tauri::command] #[tauri::command]
+3
View File
@@ -112,6 +112,7 @@ pub fn run() {
commands::move_note, commands::move_note,
commands::get_all_tags, commands::get_all_tags,
commands::get_all_note_titles, commands::get_all_note_titles,
commands::get_graph_data,
commands::search_notes, commands::search_notes,
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
@@ -121,6 +122,7 @@ pub fn run() {
commands::load_vault_state, commands::load_vault_state,
commands::save_vault_state, commands::save_vault_state,
commands::read_clipboard_image, commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image, commands::save_image,
commands::save_attachment, commands::save_attachment,
commands::get_notebook_icons, commands::get_notebook_icons,
@@ -178,6 +180,7 @@ pub fn run() {
})); }));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| { builder = builder.on_window_event(move |window, event| {
match event { match event {
+18
View File
@@ -254,3 +254,21 @@ pub struct NoteTitleEntry {
pub title: String, pub title: String,
pub path: String, pub path: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: usize,
pub target: usize,
}
+54 -2
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState}; use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
use crate::vault::frontmatter; use crate::vault::frontmatter;
use chrono::{Local, Utc}; use chrono::{Local, Locale, Utc};
use rayon::prelude::*; use rayon::prelude::*;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
@@ -449,10 +449,62 @@ pub fn create_note(
}) })
} }
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> { pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
let today = Local::now(); let today = Local::now();
let date_str = today.format("%Y-%m-%d").to_string(); let date_str = today.format("%Y-%m-%d").to_string();
let title = today.format("%B %d, %Y").to_string(); let locale = get_system_locale();
let title = today.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily"); let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+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.9", "version": "1.2.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 https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"assetProtocol": { "assetProtocol": {
"enable": true, "enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"] "scope": ["**/*", "/**", "**/.helixnotes/**"]
+91
View File
@@ -179,3 +179,94 @@ body.resizing {
display: none !important; display: none !important;
} }
} }
/* ── Print / Export PDF ── */
@media print {
/* Hide everything except editor content */
.titlebar,
.sidebar-panel,
.notelist-panel,
.resize-handle,
.toolbar-actions,
.editor-body-wrapper .note-search-bar,
.formatting-toolbar,
.editor-formatting-bar,
.history-panel,
.outline-panel,
.graph-panel,
.ai-panel,
.link-context-menu,
.image-toolbar,
.selection-bar {
display: none !important;
}
/* Force light theme colors for PDF */
:root, :root.dark {
--bg-primary: #ffffff !important;
--bg-secondary: #f8f9fa !important;
--bg-tertiary: #f0f1f3 !important;
--bg-editor: #ffffff !important;
--text-primary: #1a1a2e !important;
--text-secondary: #495057 !important;
--text-tertiary: #868e96 !important;
--text-accent: #5b6abf !important;
--accent: #5b6abf !important;
--border-color: #e2e5e9 !important;
--border-light: #f0f1f3 !important;
}
html, body {
height: auto;
overflow: visible;
background: white !important;
color: #1a1a2e !important;
font-size: 12pt;
padding: 0;
}
/* Override all scoped height/overflow constraints for print flow */
* {
overflow: visible !important;
height: auto !important;
max-height: none !important;
min-height: 0 !important;
}
.app-layout {
display: block !important;
}
.editor-panel {
width: 100% !important;
min-width: 0 !important;
}
.ProseMirror {
padding: 0 !important;
max-width: 100% !important;
background: white !important;
color: #1a1a2e !important;
}
/* Ensure images fit within page */
.ProseMirror img {
max-width: 100% !important;
page-break-inside: avoid;
}
/* Avoid breaking inside these elements */
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
}
pre, blockquote, table, figure {
page-break-inside: avoid;
}
/* Code blocks with light background */
.ProseMirror pre, .ProseMirror code {
background: #f8f9fa !important;
color: #1a1a2e !important;
}
}
+8
View File
@@ -126,6 +126,10 @@ export async function getAllNoteTitles(): Promise<NoteTitleEntry[]> {
return invoke("get_all_note_titles"); return invoke("get_all_note_titles");
} }
export async function getGraphData(): Promise<{ nodes: { title: string; path: string }[]; edges: { source: number; target: number }[] }> {
return invoke("get_graph_data");
}
export async function searchNotes( export async function searchNotes(
query: string, query: string,
limit?: number, limit?: number,
@@ -168,6 +172,10 @@ export async function readClipboardImage(): Promise<number[]> {
return invoke("read_clipboard_image"); return invoke("read_clipboard_image");
} }
export async function copyImageToClipboard(path: string): Promise<void> {
return invoke("copy_image_to_clipboard", { path });
}
export async function saveImage(name: string, data: number[]): Promise<string> { export async function saveImage(name: string, data: number[]): Promise<string> {
return invoke("save_image", { name, data }); return invoke("save_image", { name, data });
} }
+81 -30
View File
@@ -192,34 +192,49 @@
} }
// Android back gesture / hardware back button support // Android back gesture / hardware back button support
let mobileNavFromPopstate = false; // We maintain a simple counter of how many views deep we are.
// sidebar=0, notelist=1, editor=2. Each forward nav pushes, back pops.
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) { if (isMobile) {
// Seed initial history state history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
history.replaceState({ mobileView: 'sidebar' }, '');
// When mobileView changes forward, push browser history so Android back gesture works
$effect(() => { $effect(() => {
const view = $mobileView; const view = $mobileView;
if (mobileNavFromPopstate) { if (navFromPopstate) {
mobileNavFromPopstate = false; navFromPopstate = false;
return; return;
} }
// Replace state to track current view const targetDepth = view === 'sidebar' ? 0 : view === 'notelist' ? 1 : 2;
history.pushState({ mobileView: view }, ''); if (targetDepth > historyDepth) {
// Forward navigation — push entries for each level skipped
for (let d = historyDepth + 1; d <= targetDepth; d++) {
const v = d === 1 ? 'notelist' : 'editor';
history.pushState({ mobileView: v, depth: d }, '');
}
historyDepth = targetDepth;
} else if (targetDepth < historyDepth) {
// Programmatic back (e.g. mobileBack button) — go back in history
const steps = historyDepth - targetDepth;
historyDepth = targetDepth;
navFromPopstate = true; // suppress the popstate that history.go triggers
history.go(-steps);
}
}); });
window.addEventListener('popstate', (e) => { window.addEventListener('popstate', (e) => {
const currentView = $mobileView; if (navFromPopstate) {
if (currentView === 'sidebar') { navFromPopstate = false;
// Already at root — let Android handle it (exit app)
return; return;
} }
mobileNavFromPopstate = true; const state = e.state;
if (currentView === 'editor') $mobileView = 'notelist'; const targetDepth = state?.depth ?? 0;
else if (currentView === 'notelist') $mobileView = 'sidebar'; historyDepth = targetDepth;
// Push state again so next back gesture also works navFromPopstate = true;
history.pushState({ mobileView: $mobileView }, ''); if (targetDepth === 0) $mobileView = 'sidebar';
else if (targetDepth === 1) $mobileView = 'notelist';
else $mobileView = 'editor';
}); });
} }
@@ -467,11 +482,6 @@
{/if} {/if}
</svg> </svg>
</button> </button>
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</button>
<button class="mobile-header-btn" onclick={() => editor?.openNoteSearch()} title="Find in note"> <button class="mobile-header-btn" onclick={() => editor?.openNoteSearch()} title="Find in note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/> <circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
@@ -503,6 +513,14 @@
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg> </svg>
</button> </button>
{#if $appConfig?.enable_wiki_links}
<button class="mobile-header-btn" onclick={() => editor?.toggleGraphView()} title="Graph View">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="18" r="3"/>
<line x1="8.5" y1="7.5" x2="15.5" y2="16.5"/><line x1="15.5" y1="7.5" x2="8.5" y2="16.5"/>
</svg>
</button>
{/if}
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} {#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
<button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions"> <button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -510,6 +528,11 @@
</svg> </svg>
</button> </button>
{/if} {/if}
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
</svg>
</button>
{:else} {:else}
<button class="mobile-header-btn" onclick={() => ($showSearch = true)} title="Search"> <button class="mobile-header-btn" onclick={() => ($showSearch = true)} title="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -517,12 +540,7 @@
<line x1="21" y1="21" x2="16.65" y2="16.65" /> <line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg> </svg>
</button> </button>
<button class="mobile-header-btn" onclick={createAndFocusNote} title="New Note"> <button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button class="mobile-header-btn" onclick={handleDailyNote} title="Daily Note">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" /> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" /> <line x1="16" y1="2" x2="16" y2="6" />
@@ -547,6 +565,11 @@
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
@@ -780,15 +803,20 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 44px; width: 34px;
height: 44px; height: 34px;
border: none; border: none;
background: none; background: none;
color: var(--text-secondary); color: var(--text-secondary);
border-radius: 10px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.mobile-header-btn svg {
width: 16px;
height: 16px;
}
.mobile-header-btn:active { .mobile-header-btn:active {
background: var(--bg-hover); background: var(--bg-hover);
} }
@@ -818,4 +846,27 @@
pointer-events: auto; pointer-events: auto;
} }
.mobile-fab {
position: absolute;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer;
z-index: 10;
}
.mobile-fab:active {
transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style> </style>
+360 -32
View File
@@ -36,7 +36,7 @@
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { readFile } from '@tauri-apps/plugin-fs'; import { readFile } from '@tauri-apps/plugin-fs';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, copyFileTo } from '$lib/api'; import { openFile, copyFileTo, copyImageToClipboard as copyImageToClipboardCmd } from '$lib/api';
import { save as saveDialog } from '@tauri-apps/plugin-dialog'; import { save as saveDialog } from '@tauri-apps/plugin-dialog';
import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app'; import { activeNote, activeNotePath, appConfig, editorDirty, sourceMode, focusMode, readOnly, quickAccessPaths, notes } from '$lib/stores/app';
import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api'; import { saveNote, saveImage, saveAttachment, readClipboardImage, addQuickAccess, removeQuickAccess, getQuickAccess, getNoteVersions, getNoteVersionContent, createVersion, aiAsk, getAllNoteTitles, readNote, renameNote } from '$lib/api';
@@ -199,11 +199,19 @@
let linkModalInput = $state<HTMLInputElement>(null!); let linkModalInput = $state<HTMLInputElement>(null!);
let linkSelectionFrom = 0; let linkSelectionFrom = 0;
let linkSelectionTo = 0; let linkSelectionTo = 0;
let linkSuggestIndex = $state(0);
let linkSuggestTitles = $state<NoteTitleEntry[]>([]);
let linkSuggestFiltered = $derived.by(() => {
const q = linkModalUrl.trim().toLowerCase();
if (!q || q.startsWith('http://') || q.startsWith('https://') || q.startsWith('mailto:')) return [];
return linkSuggestTitles.filter(e => e.title.toLowerCase().includes(q)).slice(0, 8);
});
let textContextMenu = $state<{ x: number; y: number } | null>(null); let textContextMenu = $state<{ x: number; y: number } | null>(null);
let tableContextMenu = $state<{ x: number; y: number } | null>(null); let tableContextMenu = $state<{ x: number; y: number } | null>(null);
let tablePickerOpen = $state(false); let tablePickerOpen = $state(false);
let tablePickerHover = $state({ rows: 0, cols: 0 }); let tablePickerHover = $state({ rows: 0, cols: 0 });
let imageToolbar = $state<{ pos: number; x: number; y: number; size: string } | null>(null); let imageToolbar = $state<{ pos: number; x: number; y: number; size: string; src: string } | null>(null);
let copyToast = $state<'copying' | 'done' | null>(null);
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : ''); let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
@@ -590,6 +598,8 @@
} }
function updateSlashMenu() { function updateSlashMenu() {
const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false;
if (!editor) return; if (!editor) return;
if (slashTablePicker) return; // Table picker is open, don't interfere if (slashTablePicker) return; // Table picker is open, don't interfere
const { state } = editor; const { state } = editor;
@@ -613,10 +623,9 @@
// Only open the menu if the user typed the slash, or the menu is already open // Only open the menu if the user typed the slash, or the menu is already open
// This prevents triggering when clicking/arrowing into existing paths like /usr/local/bin // This prevents triggering when clicking/arrowing into existing paths like /usr/local/bin
if (!slashMenu && !slashTypedByUser) { if (!slashMenu && !wasSlashTyped) {
return; return;
} }
slashTypedByUser = false;
const query = match[2]; const query = match[2];
const slashOffset = textBefore.length - match[0].length + (match[1].length); // position of "/" const slashOffset = textBefore.length - match[0].length + (match[1].length); // position of "/"
@@ -627,11 +636,12 @@
const coords = editor.view.coordsAtPos(from); const coords = editor.view.coordsAtPos(from);
let x = coords.left; let x = coords.left;
let y = coords.bottom + 4;
// Keep menu within viewport // Keep menu within viewport (account for virtual keyboard on mobile)
if (x + 240 > window.innerWidth) x = window.innerWidth - 250; if (x + 240 > window.innerWidth) x = window.innerWidth - 250;
if (y + 300 > window.innerHeight) y = coords.top - 304; let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300);
slashMenu = { x, y, query, from, to }; slashMenu = { x, y, query, from, to };
slashSelectedIndex = 0; slashSelectedIndex = 0;
@@ -731,6 +741,7 @@
let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null); let wikiLinkMenu = $state<{ x: number; y: number; query: string; from: number } | null>(null);
let wikiLinkSelectedIndex = $state(0); let wikiLinkSelectedIndex = $state(0);
let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]); let wikiLinkTitlesCache = $state<NoteTitleEntry[]>([]);
let wikiLinkTypedByUser = false;
let wikiLinkFiltered = $derived.by(() => { let wikiLinkFiltered = $derived.by(() => {
if (!wikiLinkMenu) return wikiLinkTitlesCache; if (!wikiLinkMenu) return wikiLinkTitlesCache;
@@ -800,8 +811,20 @@
}, },
parseHTML() { parseHTML() {
return [ return [
{ tag: 'span[data-wiki-link]' }, {
{ tag: 'a[data-wiki-link]' }, tag: 'span[data-wiki-link]',
getAttrs: (el: HTMLElement) => ({
title: el.getAttribute('data-title') || null,
path: el.getAttribute('data-path') || null,
}),
},
{
tag: 'a[data-wiki-link]',
getAttrs: (el: HTMLElement) => ({
title: el.getAttribute('data-title') || null,
path: el.getAttribute('data-path') || null,
}),
},
]; ];
}, },
renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) { renderHTML({ HTMLAttributes }: { HTMLAttributes: Record<string, any> }) {
@@ -875,6 +898,11 @@
}, },
handleTextInput: (view, from, to, text) => { handleTextInput: (view, from, to, text) => {
if (!$appConfig?.enable_wiki_links) return false; if (!$appConfig?.enable_wiki_links) return false;
// Detect [[ opening: flag so onTransaction opens the menu on mobile
if (text === '[') {
const charBefore = from > 0 ? view.state.doc.textBetween(from - 1, from) : '';
if (charBefore === '[') wikiLinkTypedByUser = true;
}
// Detect ]] closing: auto-resolve the current text as a wiki-link // Detect ]] closing: auto-resolve the current text as a wiki-link
if (text === ']' && wikiLinkMenu) { if (text === ']' && wikiLinkMenu) {
const state = view.state; const state = view.state;
@@ -922,6 +950,7 @@
}); });
function updateWikiLinkMenu() { function updateWikiLinkMenu() {
wikiLinkTypedByUser = false;
if (!editor || !$appConfig?.enable_wiki_links) return; if (!editor || !$appConfig?.enable_wiki_links) return;
const { state } = editor; const { state } = editor;
const { selection } = state; const { selection } = state;
@@ -931,7 +960,16 @@
closeWikiLinkMenu(); closeWikiLinkMenu();
return; return;
} }
const textBefore = parentNode.textContent.slice(0, resolvedFrom.parentOffset); // Build textBefore from the actual ProseMirror node content so positions are accurate
// (parentNode.textContent flattens images/atoms, causing position miscalculation)
let textBefore = '';
const cursorOffset = resolvedFrom.parentOffset;
parentNode.forEach((child, offset) => {
if (offset >= cursorOffset) return false;
if (child.isText) {
textBefore += child.text!.slice(0, Math.min(child.nodeSize, cursorOffset - offset));
}
});
// Match [[ at start of line or after whitespace // Match [[ at start of line or after whitespace
const match = textBefore.match(/\[\[([^\]]*)$/); const match = textBefore.match(/\[\[([^\]]*)$/);
if (!match) { if (!match) {
@@ -941,13 +979,14 @@
// Refresh titles when the menu first opens so newly created notes are found // Refresh titles when the menu first opens so newly created notes are found
if (!wikiLinkMenu) refreshWikiLinkTitles(); if (!wikiLinkMenu) refreshWikiLinkTitles();
const query = match[1]; const query = match[1];
const bracketOffset = textBefore.length - match[0].length; // Calculate from as cursor position minus the matched text length ("[[query")
const from = resolvedFrom.start() + bracketOffset; const from = resolvedFrom.pos - match[0].length;
const coords = editor.view.coordsAtPos(from); const coords = editor.view.coordsAtPos(from);
let x = coords.left; let x = coords.left;
let y = coords.bottom + 4;
if (x + 280 > window.innerWidth) x = window.innerWidth - 290; if (x + 280 > window.innerWidth) x = window.innerWidth - 290;
if (y + 300 > window.innerHeight) y = coords.top - 304; let y = coords.bottom + 4;
const visibleBottom = window.innerHeight - keyboardHeight;
if (y + 300 > visibleBottom) y = Math.max(4, visibleBottom - 300);
wikiLinkMenu = { x, y, query, from }; wikiLinkMenu = { x, y, query, from };
wikiLinkSelectedIndex = 0; wikiLinkSelectedIndex = 0;
} }
@@ -1791,7 +1830,12 @@
.replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n') .replace(/<p[^>]*>(.*?)<\/p>/gi, '> $1\n')
.replace(/<br\s*\/?>/gi, '\n> '); .replace(/<br\s*\/?>/gi, '\n> ');
}); });
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, (_m, href, text) => {
// Decode percent-encoded href back to readable form for markdown source
// Spaces are re-encoded by markdownToHtml preprocessing before markdown-it parsing
const decoded = decodeURIComponent(href);
return `[${text}](${decoded})`;
});
md = md.replace(/<img[^>]*>/gi, (match) => { md = md.replace(/<img[^>]*>/gi, (match) => {
const srcMatch = match.match(/src="([^"]*)"/); const srcMatch = match.match(/src="([^"]*)"/);
const altMatch = match.match(/alt="([^"]*)"/); const altMatch = match.match(/alt="([^"]*)"/);
@@ -1888,6 +1932,12 @@
return `![${alt}](${url.replace(/ /g, '%20')})`; return `![${alt}](${url.replace(/ /g, '%20')})`;
}); });
// Pre-process: percent-encode spaces in link URLs so markdown-it parses them correctly
// Matches [text](url with spaces) but not ![image](url) (already handled above)
src = src.replace(/(?<!!)\[([^\]]*)\]\(([^)]*\s[^)]*)\)/g, (_match, text, url) => {
return `[${text}](${url.replace(/ /g, '%20')})`;
});
// Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile // Pre-process: transform PDF embed divs — iframes on desktop, clickable links on mobile
src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => { src = src.replace(/<div[^>]*data-pdf-src="([^"]*)"[^>]*data-pdf-name="([^"]*)"[^>]*>[^<]*<\/div>/gi, (_, pdfSrc, name) => {
const vaultRoot = $appConfig?.active_vault ?? ''; const vaultRoot = $appConfig?.active_vault ?? '';
@@ -2156,9 +2206,9 @@
editorState++; editorState++;
}); });
} }
// On mobile, only check menus when they're already open (avoid work on every keystroke) // On mobile, only check menus when they're already open or user just typed trigger char
if (!isMobile || slashMenu) updateSlashMenu(); if (!isMobile || slashMenu || slashTypedByUser) updateSlashMenu();
if (!isMobile || wikiLinkMenu) updateWikiLinkMenu(); if (!isMobile || wikiLinkMenu || wikiLinkTypedByUser) updateWikiLinkMenu();
}, },
onUpdate: () => { onUpdate: () => {
if (ignoreNextUpdate || isLoadingNote) { if (ignoreNextUpdate || isLoadingNote) {
@@ -2190,14 +2240,21 @@
openAiMenu(); openAiMenu();
} }
export function toggleGraphView() {
showGraph = !showGraph;
}
export function addLinkFromToolbar() { export function addLinkFromToolbar() {
if (!editor) return; if (!editor) return;
const { from, to } = editor.state.selection; const { from, to } = editor.state.selection;
linkSelectionFrom = from; linkSelectionFrom = from;
linkSelectionTo = to; linkSelectionTo = to;
const previousUrl = editor.getAttributes('link').href || ''; const previousUrl = editor.getAttributes('link').href || '';
linkModalUrl = previousUrl; linkModalUrl = decodeURIComponent(previousUrl);
linkSuggestIndex = 0;
linkModal = true; linkModal = true;
// Load note titles for autocomplete
getAllNoteTitles().then(t => { linkSuggestTitles = t; }).catch(() => {});
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -2207,15 +2264,37 @@
if (url === '') { if (url === '') {
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run(); editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).extendMarkRange('link').unsetLink().run();
} else { } else {
if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#')) { if (url && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith('/') && !url.startsWith('#') && !url.endsWith('.md')) {
url = 'https://' + url; url = 'https://' + url;
} }
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href: url }).run(); // Store raw URL — encoding is handled during markdown serialization/parsing
const href = url.replace(/[()]/g, (c) => encodeURIComponent(c));
editor.chain().focus().setTextSelection({ from: linkSelectionFrom, to: linkSelectionTo }).setMark('link', { href }).run();
} }
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
} }
function linkModalSelectNote(entry: NoteTitleEntry) {
// Build a relative .md path from the selected note and confirm immediately
const vaultRoot = $appConfig?.active_vault;
const currentNote = $activeNotePath;
if (vaultRoot && currentNote) {
const noteDir = currentNote.substring(0, currentNote.lastIndexOf('/'));
const targetRel = entry.path.startsWith(vaultRoot) ? entry.path.substring(vaultRoot.length + 1) : entry.path;
const currentRel = noteDir.startsWith(vaultRoot) ? noteDir.substring(vaultRoot.length + 1) : noteDir;
const targetParts = targetRel.split('/');
const currentParts = currentRel ? currentRel.split('/') : [];
let common = 0;
while (common < targetParts.length && common < currentParts.length && targetParts[common] === currentParts[common]) common++;
const ups = currentParts.length - common;
linkModalUrl = (ups > 0 ? '../'.repeat(ups) : './') + targetParts.slice(common).join('/');
} else {
linkModalUrl = entry.title + '.md';
}
linkModalConfirm();
}
function linkModalCancel() { function linkModalCancel() {
linkModal = false; linkModal = false;
linkModalUrl = ''; linkModalUrl = '';
@@ -2250,12 +2329,12 @@
} }
function handleEditorClick(event: MouseEvent) { function handleEditorClick(event: MouseEvent) {
imageToolbar = null;
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
// Wiki-link click — navigate to linked note // Wiki-link click — navigate to linked note
const wikiLinkEl = target.closest('span[data-wiki-link]') as HTMLElement | null; const wikiLinkEl = target.closest('span[data-wiki-link]') as HTMLElement | null;
if (wikiLinkEl) { if (wikiLinkEl) {
imageToolbar = null;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const path = wikiLinkEl.getAttribute('data-path') || ''; const path = wikiLinkEl.getAttribute('data-path') || '';
@@ -2264,21 +2343,31 @@
return; return;
} }
// Image click — show size toolbar // Image click — toggle size toolbar
if (target.tagName === 'IMG' && editor) { if (target.tagName === 'IMG' && editor) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const pos = editor.view.posAtDOM(target, 0); const pos = editor.view.posAtDOM(target, 0);
const rect = target.getBoundingClientRect(); // If toolbar is already open for this image, close it
if (imageToolbar && imageToolbar.pos === pos) {
imageToolbar = null;
return;
}
const node = editor.state.doc.nodeAt(pos); const node = editor.state.doc.nodeAt(pos);
const currentSize = node?.attrs.size || 'full'; const currentSize = node?.attrs.size || 'full';
imageToolbar = { pos, x: rect.left + rect.width / 2, y: rect.top - 8, size: currentSize }; const imgSrc = node?.attrs.src || (target as HTMLImageElement).src || '';
const toolbarW = isMobile ? 130 : 250;
const toolbarH = 38;
const x = Math.min(event.clientX, window.innerWidth - toolbarW - 8);
const y = Math.min(event.clientY, window.innerHeight - toolbarH - 8);
imageToolbar = { pos, x, y, size: currentSize, src: imgSrc };
// Move cursor after the image to clear ProseMirror's node selection highlight // Move cursor after the image to clear ProseMirror's node selection highlight
const afterPos = pos + (node?.nodeSize || 1); const afterPos = pos + (node?.nodeSize || 1);
editor.chain().setTextSelection(afterPos).run(); editor.chain().setTextSelection(afterPos).run();
return; return;
} }
imageToolbar = null;
} }
function setImageSize(size: string) { function setImageSize(size: string) {
@@ -2291,6 +2380,62 @@
autoSave(); autoSave();
} }
function getImageAbsPath(src: string): string {
// asset:// or http://asset.localhost → extract absolute path
if (src.startsWith('asset:') || src.startsWith('http://asset.localhost')) {
try {
const url = new URL(src);
let absPath = decodeURIComponent(url.pathname);
absPath = absPath.replace(/^\/{2,}/, '/');
return absPath;
} catch { /* fall through */ }
}
// Relative path → resolve against note directory
let decoded = decodeURIComponent(src);
if (decoded.match(/^\/{2,}/)) decoded = decoded.replace(/^\/{2,}/, '/');
if (decoded.startsWith('/')) return decoded;
if (decoded.includes('.helixnotes/')) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) {
const idx = decoded.indexOf('.helixnotes/');
return `${vaultRoot}/${decoded.substring(idx)}`;
}
}
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
return normalizePath(`${noteDir}/${decoded}`);
}
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) return normalizePath(`${vaultRoot}/${decoded}`);
return src;
}
async function copyImageToClipboard() {
if (!imageToolbar) return;
const absPath = getImageAbsPath(imageToolbar.src);
imageToolbar = null;
copyToast = 'copying';
// Yield to let Svelte render the "Copying..." toast before blocking on IPC
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
try {
await copyImageToClipboardCmd(absPath);
copyToast = 'done';
} catch (e) {
console.error('Failed to copy image:', e);
copyToast = null;
return;
}
setTimeout(() => { copyToast = null; }, 1000);
}
function openImageInApp() {
if (!imageToolbar) return;
const absPath = getImageAbsPath(imageToolbar.src);
openFile(absPath).catch(e => console.error('Failed to open image:', e));
imageToolbar = null;
}
function handleEditorContextMenu(event: MouseEvent) { function handleEditorContextMenu(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
const anchor = target.closest('a'); const anchor = target.closest('a');
@@ -2691,19 +2836,43 @@
linkContextMenu = null; linkContextMenu = null;
} }
/** Resolve a link href to an absolute .md note path, or null if not a note link. */
function resolveNoteHref(href: string): string | null {
const decoded = decodeURIComponent(href);
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) return null;
let absPath = decoded;
if (!decoded.startsWith('/')) {
const notePath = $activeNotePath;
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
return absPath.endsWith('.md') ? absPath : null;
}
function linkMenuOpen() { function linkMenuOpen() {
if (!linkContextMenu) return; if (!linkContextMenu) return;
const href = linkContextMenu.href; const href = linkContextMenu.href;
closeLinkContextMenu(); closeLinkContextMenu();
// Internal .md note link — navigate within the app
const notePath = resolveNoteHref(href);
if (notePath) {
navigateToWikiLink(notePath, '');
return;
}
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch(console.error); openUrl(href).catch(console.error);
} else { } else {
const decoded = decodeURIComponent(href); const decoded = decodeURIComponent(href);
let absPath = decoded; let absPath = decoded;
if (!decoded.startsWith('/')) { if (!decoded.startsWith('/')) {
const notePath = $activeNotePath; const np = $activeNotePath;
if (notePath) { if (np) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/')); const noteDir = np.substring(0, np.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`); absPath = normalizePath(`${noteDir}/${decoded}`);
} else { } else {
const vaultRoot = $appConfig?.active_vault; const vaultRoot = $appConfig?.active_vault;
@@ -2730,7 +2899,7 @@
if (pos >= 0) { if (pos >= 0) {
editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run(); editor.chain().focus().setTextSelection(pos).extendMarkRange('link').run();
} }
linkModalUrl = href; linkModalUrl = decodeURIComponent(href);
linkModal = true; linkModal = true;
tick().then(() => linkModalInput?.focus()); tick().then(() => linkModalInput?.focus());
} }
@@ -4189,10 +4358,36 @@
<button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button> <button class:active={imageToolbar.size === 'small'} onclick={() => setImageSize('small')} title="Small (33%)">S</button>
<button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button> <button class:active={imageToolbar.size === 'medium'} onclick={() => setImageSize('medium')} title="Medium (50%)">M</button>
<button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button> <button class:active={imageToolbar.size === 'full'} onclick={() => setImageSize('full')} title="Full width">L</button>
{#if !isMobile}
<span class="img-toolbar-sep"></span>
<button onclick={copyImageToClipboard} title="Copy image">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
</button>
<button onclick={openImageInApp} title="Open in default app">
<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="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>
</button>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
{#if copyToast}
<div class="copy-toast" class:done={copyToast === 'done'}>
{#if copyToast === 'copying'}
<svg class="copy-toast-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<circle cx="12" cy="12" r="10" opacity="0.25" />
<path d="M12 2a10 10 0 019.95 9" />
</svg>
Copying...
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Copied
{/if}
</div>
{/if}
{#if codeLangDropdown} {#if codeLangDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}> <div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
@@ -4446,12 +4641,34 @@
class="link-modal-input" class="link-modal-input"
bind:this={linkModalInput} bind:this={linkModalInput}
bind:value={linkModalUrl} bind:value={linkModalUrl}
oninput={() => { linkSuggestIndex = 0; }}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); } if (linkSuggestFiltered.length > 0) {
if (e.key === 'ArrowDown') { e.preventDefault(); linkSuggestIndex = Math.min(linkSuggestIndex + 1, linkSuggestFiltered.length - 1); return; }
if (e.key === 'ArrowUp') { e.preventDefault(); linkSuggestIndex = Math.max(linkSuggestIndex - 1, 0); return; }
if (e.key === 'Enter') { e.preventDefault(); linkModalSelectNote(linkSuggestFiltered[linkSuggestIndex]); return; }
} else {
if (e.key === 'Enter') { e.preventDefault(); linkModalConfirm(); }
}
if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); } if (e.key === 'Escape') { e.preventDefault(); linkModalCancel(); }
}} }}
placeholder="https://example.com" placeholder="URL or note name"
/> />
{#if linkSuggestFiltered.length > 0}
<div class="link-suggest-list">
{#each linkSuggestFiltered as entry, i}
<button
class="link-suggest-item"
class:selected={i === linkSuggestIndex}
onmouseenter={() => linkSuggestIndex = i}
onmousedown={(e) => { e.preventDefault(); linkModalSelectNote(entry); }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
<span class="link-suggest-title">{entry.title}</span>
</button>
{/each}
</div>
{/if}
<div class="link-modal-actions"> <div class="link-modal-actions">
<button class="link-modal-btn cancel" onclick={linkModalCancel}>Cancel</button> <button class="link-modal-btn cancel" onclick={linkModalCancel}>Cancel</button>
<button class="link-modal-btn confirm" onclick={linkModalConfirm}> <button class="link-modal-btn confirm" onclick={linkModalConfirm}>
@@ -5489,10 +5706,27 @@
text-decoration-color: color-mix(in srgb, var(--text-accent) 40%, transparent); text-decoration-color: color-mix(in srgb, var(--text-accent) 40%, transparent);
} }
:global(.tiptap-wrapper .tiptap a::after) {
content: '↗';
display: inline;
font-size: 0.65em;
margin-left: 2px;
opacity: 0.5;
vertical-align: 15%;
}
:global(.tiptap-wrapper .tiptap a[href$=".md"]::after) {
content: '⤴';
}
:global(.tiptap-wrapper .tiptap a:hover) { :global(.tiptap-wrapper .tiptap a:hover) {
text-decoration-color: var(--text-accent); text-decoration-color: var(--text-accent);
} }
:global(.tiptap-wrapper .tiptap a:hover::after) {
opacity: 0.8;
}
:global(.tiptap-wrapper .tiptap img) { :global(.tiptap-wrapper .tiptap img) {
display: block; display: block;
max-width: 100%; max-width: 100%;
@@ -5544,8 +5778,8 @@
.img-toolbar { .img-toolbar {
position: fixed; position: fixed;
transform: translateX(-50%) translateY(-100%);
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
background: var(--bg-primary); background: var(--bg-primary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -5576,6 +5810,54 @@
color: white; color: white;
} }
.img-toolbar-sep {
width: 1px;
height: 16px;
background: var(--border-color);
margin: 0 2px;
}
.img-toolbar button svg {
display: block;
}
.copy-toast {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
min-width: 100px;
justify-content: center;
background: var(--accent);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
font-size: 13px;
font-weight: 500;
color: white;
z-index: 9999;
animation: toast-in 0.15s ease-out;
}
.copy-toast.done {
background: var(--accent);
}
.copy-toast-spinner {
animation: copy-spin 0.8s linear infinite;
}
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes copy-spin {
to { transform: rotate(360deg); }
}
:global(.tiptap-wrapper .tiptap mark) { :global(.tiptap-wrapper .tiptap mark) {
padding: 0px 5px 2px; padding: 0px 5px 2px;
border-radius: 3px; border-radius: 3px;
@@ -5802,6 +6084,52 @@
opacity: 0.9; opacity: 0.9;
} }
.link-suggest-list {
max-height: 240px;
overflow-y: auto;
margin-top: 8px;
border: 1px solid var(--border-light);
border-radius: 8px;
padding: 4px;
}
.link-suggest-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
border-radius: 6px;
background: none;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.link-suggest-item:hover,
.link-suggest-item.selected {
background: var(--accent-light);
color: var(--accent);
}
.link-suggest-item svg {
flex-shrink: 0;
color: var(--text-tertiary);
}
.link-suggest-item:hover svg,
.link-suggest-item.selected svg {
color: var(--accent);
}
.link-suggest-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Text context menu */ /* Text context menu */
.text-ctx-overlay { .text-ctx-overlay {
position: fixed; position: fixed;
+177 -98
View File
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { getAllNoteTitles, readNote } from '$lib/api'; import { getGraphData } from '$lib/api';
import { appConfig, activeNotePath } from '$lib/stores/app'; import { activeNotePath } from '$lib/stores/app';
import type { NoteTitleEntry } from '$lib/types';
let { onclose, onnavigate }: { let { onclose, onnavigate }: {
onclose: () => void; onclose: () => void;
@@ -12,6 +11,9 @@
let canvas = $state<HTMLCanvasElement>(null!); let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true); let loading = $state(true);
// Start fetching data immediately — don't wait for canvas mount
const dataPromise = getGraphData();
interface GraphNode { interface GraphNode {
id: string; id: string;
title: string; title: string;
@@ -31,7 +33,6 @@
let edges: GraphEdge[] = []; let edges: GraphEdge[] = [];
let nodeIndexMap: Map<string, number> = new Map(); let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set(); let connectedSet: Set<number> = new Set();
let animFrame = 0;
let pan = { x: 0, y: 0 }; let pan = { x: 0, y: 0 };
let zoom = 1; let zoom = 1;
let dragging: GraphNode | null = null; let dragging: GraphNode | null = null;
@@ -41,75 +42,54 @@
let panStart = { x: 0, y: 0 }; let panStart = { x: 0, y: 0 };
let hoveredNode: GraphNode | null = null; let hoveredNode: GraphNode | null = null;
let glowPhase = 0; let glowPhase = 0;
let glowFrame = 0;
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g; // Cache computed styles — read once, not every frame
let cachedStyles: { border: string; text: string; textSec: string; accent: string } | null = null;
function getStyles() {
if (cachedStyles) return cachedStyles;
const style = getComputedStyle(document.documentElement);
cachedStyles = {
border: style.getPropertyValue('--border-color').trim() || '#444',
text: style.getPropertyValue('--text-primary').trim() || '#eee',
textSec: style.getPropertyValue('--text-tertiary').trim() || '#888',
accent: style.getPropertyValue('--accent').trim() || '#7b9bd4',
};
return cachedStyles;
}
async function buildGraph() { async function buildGraph() {
loading = true; loading = true;
try { try {
const titles = await getAllNoteTitles(); // Use the pre-fetched promise (started before canvas mount)
const titleMap = new Map<string, NoteTitleEntry>(); const data = await dataPromise;
for (const t of titles) {
titleMap.set(t.title.toLowerCase(), t);
}
// Create nodes
const w = canvas?.width ?? 800; const w = canvas?.width ?? 800;
const h = canvas?.height ?? 600; const h = canvas?.height ?? 600;
nodes = titles.map((t) => ({
id: t.title.toLowerCase(),
title: t.title,
path: t.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
}));
// Build index map for O(1) lookups // Build node index map
nodeIndexMap = new Map(); nodeIndexMap = new Map();
for (let i = 0; i < nodes.length; i++) { nodes = data.nodes.map((n, i) => {
nodeIndexMap.set(nodes[i].id, i); nodeIndexMap.set(n.title.toLowerCase(), i);
} return {
id: n.title.toLowerCase(),
title: n.title,
path: n.path,
x: w / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0,
vy: 0,
};
});
// Read all notes in parallel (batched) to extract [[wiki-links]] // Map edges and track connected nodes
const edgeSet = new Set<string>(); connectedSet = new Set();
edges = []; edges = data.edges.map(e => {
const BATCH_SIZE = 20; connectedSet.add(e.source);
for (let b = 0; b < nodes.length; b += BATCH_SIZE) { connectedSet.add(e.target);
const batch = nodes.slice(b, b + BATCH_SIZE); return { sourceIdx: e.source, targetIdx: e.target };
const results = await Promise.allSettled( });
batch.map(async (node) => {
const content = await readNote(node.path);
return { node, body: content.content || '' };
})
);
for (const result of results) {
if (result.status !== 'fulfilled') continue;
const { node, body } = result.value;
const nodeIdx = nodeIndexMap.get(node.id)!;
let match;
wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) {
// Handle Obsidian syntax: strip |alias, #heading, ^block
let rawLink = match[1].trim();
const pipeIdx = rawLink.indexOf('|');
if (pipeIdx >= 0) rawLink = rawLink.slice(0, pipeIdx).trim();
rawLink = rawLink.replace(/#.*$/, '').replace(/\^.*$/, '').trim();
const linkTitle = rawLink.toLowerCase();
const targetIdx = nodeIndexMap.get(linkTitle);
if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey);
edges.push({ sourceIdx: nodeIdx, targetIdx });
connectedSet.add(nodeIdx);
connectedSet.add(targetIdx);
}
}
}
}
}
} catch (e) { } catch (e) {
console.error('Failed to build graph:', e); console.error('Failed to build graph:', e);
} }
@@ -121,17 +101,17 @@
if (!canvas || nodes.length === 0) return; if (!canvas || nodes.length === 0) return;
const activePath = $activeNotePath || ''; const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath); const activeNode = nodes.find(n => n.path === activePath);
// Only center on active note if it has connections
if (!activeNode) return; if (!activeNode) return;
const activeIdx = nodeIndexMap.get(activeNode.id); const activeIdx = nodeIndexMap.get(activeNode.id);
if (activeIdx === undefined || !connectedSet.has(activeIdx)) return;
// Gather the active node and its direct neighbors // Gather the active node and its direct neighbors
const neighborhood: GraphNode[] = [activeNode]; const neighborhood: GraphNode[] = [activeNode];
for (const edge of edges) { if (activeIdx !== undefined) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]); for (const edge of edges) {
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]); if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
}
} }
const w = canvas.width; const w = canvas.width;
@@ -193,18 +173,13 @@
} }
function startSimulation() { function startSimulation() {
if (animFrame) cancelAnimationFrame(animFrame); // Run a small batch synchronously for an instant first render
for (let i = 0; i < 30; i++) simulate();
// Run physics synchronously — no need to animate the settling // Center/fit immediately so the user sees something right away
for (let i = 0; i < 300; i++) {
simulate();
}
// Center on active note if it has links, otherwise fit all
const activePath = $activeNotePath || ''; const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath); const activeNode = nodes.find(n => n.path === activePath);
const activeIdx = activeNode ? nodeIndexMap.get(activeNode.id) : undefined; if (activeNode) {
if (activeIdx !== undefined && connectedSet.has(activeIdx)) {
centerOnActiveNote(); centerOnActiveNote();
} else { } else {
fitToView(); fitToView();
@@ -212,9 +187,21 @@
draw(); draw();
startGlowLoop(); startGlowLoop();
}
let glowFrame = 0; // Continue settling asynchronously in small batches
const totalRemaining = Math.min(270, Math.max(70, nodes.length * 2));
let done = 0;
function settle() {
if (done >= totalRemaining) return;
const batch = Math.min(20, totalRemaining - done);
for (let i = 0; i < batch; i++) simulate();
done += batch;
// Re-center while settling
if (activeNode) centerOnActiveNote(); else fitToView();
requestAnimationFrame(settle);
}
requestAnimationFrame(settle);
}
function startGlowLoop() { function startGlowLoop() {
if (glowFrame) cancelAnimationFrame(glowFrame); if (glowFrame) cancelAnimationFrame(glowFrame);
@@ -235,16 +222,19 @@
const centerX = w / 2; const centerX = w / 2;
const centerY = h / 2; const centerY = h / 2;
// Repulsion between all nodes // Repulsion between all nodes (skip pairs that are very far apart)
for (let i = 0; i < nodeCount; i++) { for (let i = 0; i < nodeCount; i++) {
const a = nodes[i]; const a = nodes[i];
for (let j = i + 1; j < nodeCount; j++) { for (let j = i + 1; j < nodeCount; j++) {
const b = nodes[j]; const b = nodes[j];
const dx = b.x - a.x; const dx = b.x - a.x;
const dy = b.y - a.y; const dy = b.y - a.y;
const distSq = dx * dx + dy * dy || 1; const distSq = dx * dx + dy * dy;
const force = 800 / distSq; // Skip very distant pairs — negligible force
const dist = Math.sqrt(distSq); if (distSq > 250000) continue;
const d = distSq || 1;
const force = 800 / d;
const dist = Math.sqrt(d);
const fx = (dx / dist) * force; const fx = (dx / dist) * force;
const fy = (dy / dist) * force; const fy = (dy / dist) * force;
a.vx -= fx; a.vx -= fx;
@@ -254,7 +244,7 @@
} }
} }
// Attraction along edges (indexed lookups) // Attraction along edges
for (const edge of edges) { for (const edge of edges) {
const a = nodes[edge.sourceIdx]; const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx]; const b = nodes[edge.targetIdx];
@@ -298,24 +288,20 @@
ctx.translate(pan.x, pan.y); ctx.translate(pan.x, pan.y);
ctx.scale(zoom, zoom); ctx.scale(zoom, zoom);
const style = getComputedStyle(document.documentElement); const { border: borderColor, text: textColor, textSec: textSecondary, accent } = getStyles();
const borderColor = style.getPropertyValue('--border-color').trim() || '#444';
const textColor = style.getPropertyValue('--text-primary').trim() || '#eee';
const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888';
const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4';
// Draw edges // Draw edges
ctx.strokeStyle = borderColor; ctx.strokeStyle = borderColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.globalAlpha = 0.4; ctx.globalAlpha = 0.4;
ctx.beginPath();
for (const edge of edges) { for (const edge of edges) {
const a = nodes[edge.sourceIdx]; const a = nodes[edge.sourceIdx];
const b = nodes[edge.targetIdx]; const b = nodes[edge.targetIdx];
ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
ctx.stroke();
} }
ctx.stroke();
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
// Determine active note // Determine active note
@@ -369,7 +355,7 @@
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
// Label // Label — only for connected/active/hovered nodes
if (isActive || isHovered || hasLinks) { if (isActive || isHovered || hasLinks) {
ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`; ctx.font = `${isActive ? 'bold 13' : isHovered ? '12' : '10'}px -apple-system, BlinkMacSystemFont, sans-serif`;
ctx.fillStyle = isActive || isHovered ? textColor : textSecondary; ctx.fillStyle = isActive || isHovered ? textColor : textSecondary;
@@ -465,6 +451,84 @@
if (e.key === 'Escape') onclose(); if (e.key === 'Escape') onclose();
} }
// Touch support for mobile
let lastTouchDist = 0;
function handleTouchStart(e: TouchEvent) {
if (e.touches.length === 1) {
const t = e.touches[0];
mouseDownPos = { x: t.clientX, y: t.clientY };
dragMoved = false;
const node = getNodeAt(t.clientX, t.clientY);
if (node) {
dragging = node;
} else {
panning = true;
panStart = { x: t.clientX - pan.x, y: t.clientY - pan.y };
}
} else if (e.touches.length === 2) {
dragging = null;
panning = false;
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
lastTouchDist = Math.sqrt(dx * dx + dy * dy);
}
}
function handleTouchMove(e: TouchEvent) {
e.preventDefault();
if (e.touches.length === 1) {
const t = e.touches[0];
const dx = t.clientX - mouseDownPos.x;
const dy = t.clientY - mouseDownPos.y;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragMoved = true;
if (dragging && dragMoved) {
const rect = canvas.getBoundingClientRect();
dragging.x = (t.clientX - rect.left - pan.x) / zoom;
dragging.y = (t.clientY - rect.top - pan.y) / zoom;
dragging.vx = 0;
dragging.vy = 0;
draw();
} else if (panning) {
pan.x = t.clientX - panStart.x;
pan.y = t.clientY - panStart.y;
draw();
}
} else if (e.touches.length === 2) {
const dx = e.touches[0].clientX - e.touches[1].clientX;
const dy = e.touches[0].clientY - e.touches[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (lastTouchDist > 0) {
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2;
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2;
const rect = canvas.getBoundingClientRect();
const mx = midX - rect.left;
const my = midY - rect.top;
const oldZoom = zoom;
zoom = Math.max(0.2, Math.min(5, zoom * (dist / lastTouchDist)));
pan.x = mx - (mx - pan.x) * (zoom / oldZoom);
pan.y = my - (my - pan.y) * (zoom / oldZoom);
draw();
}
lastTouchDist = dist;
}
}
function handleTouchEnd(e: TouchEvent) {
if (e.touches.length === 0) {
if (dragging && !dragMoved) {
const node = dragging;
dragging = null;
onnavigate(node.path, node.title);
return;
}
dragging = null;
panning = false;
lastTouchDist = 0;
}
}
$effect(() => { $effect(() => {
if (canvas) { if (canvas) {
const rect = canvas.parentElement?.getBoundingClientRect(); const rect = canvas.parentElement?.getBoundingClientRect();
@@ -477,7 +541,6 @@
}); });
onDestroy(() => { onDestroy(() => {
if (animFrame) cancelAnimationFrame(animFrame);
if (glowFrame) cancelAnimationFrame(glowFrame); if (glowFrame) cancelAnimationFrame(glowFrame);
}); });
</script> </script>
@@ -516,6 +579,9 @@
onmousemove={handleMouseMove} onmousemove={handleMouseMove}
onmouseup={handleMouseUp} onmouseup={handleMouseUp}
onwheel={handleWheel} onwheel={handleWheel}
ontouchstart={handleTouchStart}
ontouchmove={handleTouchMove}
ontouchend={handleTouchEnd}
></canvas> ></canvas>
</div> </div>
</div> </div>
@@ -555,6 +621,20 @@
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 768px) {
.graph-panel {
width: 100vw;
height: 100vh;
max-width: none;
max-height: none;
border-radius: 0;
border: none;
}
.graph-header {
padding-top: calc(env(safe-area-inset-top, 36px) + 14px);
}
}
.graph-header h3 { .graph-header h3 {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
@@ -594,6 +674,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
cursor: grab; cursor: grab;
touch-action: none;
} }
.graph-canvas:active { .graph-canvas:active {
@@ -604,21 +685,19 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 12px; gap: 10px;
color: var(--text-tertiary);
font-size: 13px; font-size: 13px;
color: var(--text-tertiary);
z-index: 1; z-index: 1;
} }
.spinner { .spinner {
animation: spin 0.8s linear infinite; animation: spin 1s linear infinite;
} }
@keyframes spin { @keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
</style> </style>
+133 -3
View File
@@ -80,6 +80,52 @@
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
let batchTagEdit = $state(false); let batchTagEdit = $state(false);
// Mobile long-press selection
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
let longPressTriggered = false;
let touchStartPos = { x: 0, y: 0 };
const LONG_PRESS_MS = 500;
const LONG_PRESS_MOVE_THRESHOLD = 10;
function handleTouchStart(e: TouchEvent, note: NoteEntry) {
longPressTriggered = false;
const touch = e.touches[0];
touchStartPos = { x: touch.clientX, y: touch.clientY };
longPressTimer = setTimeout(() => {
longPressTriggered = true;
// Vibrate for haptic feedback if available
if (navigator.vibrate) navigator.vibrate(30);
const next = new Set(selectedPaths);
if (next.size === 0 && $activeNotePath && $activeNotePath !== note.path) {
next.add($activeNotePath);
}
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
}, LONG_PRESS_MS);
}
function handleTouchMove(e: TouchEvent) {
if (!longPressTimer) return;
const touch = e.touches[0];
const dx = touch.clientX - touchStartPos.x;
const dy = touch.clientY - touchStartPos.y;
if (Math.abs(dx) > LONG_PRESS_MOVE_THRESHOLD || Math.abs(dy) > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
function handleTouchEnd() {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
}
// Quick Access drag-to-reorder // Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
@@ -504,6 +550,23 @@
} }
function handleNoteClick(e: MouseEvent, note: NoteEntry) { function handleNoteClick(e: MouseEvent, note: NoteEntry) {
// Mobile: if long-press just fired, ignore the click
if (isMobile && longPressTriggered) {
longPressTriggered = false;
e.preventDefault();
return;
}
// Mobile: if in selection mode, tap toggles selection
if (isMobile && selectedPaths.size > 0) {
const next = new Set(selectedPaths);
if (next.has(note.path)) {
next.delete(note.path);
} else {
next.add(note.path);
}
selectedPaths = next;
return;
}
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
// Toggle individual selection // Toggle individual selection
const next = new Set(selectedPaths); const next = new Set(selectedPaths);
@@ -599,7 +662,11 @@
function openSortMenu(e: MouseEvent) { function openSortMenu(e: MouseEvent) {
e.stopPropagation(); e.stopPropagation();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
sortMenu = { x: rect.left, y: rect.bottom + 4 }; const menuWidth = isMobile ? 220 : 180;
let x = rect.left;
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (x < 4) x = 4;
sortMenu = { x, y: rect.bottom + 4 };
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
@@ -632,8 +699,8 @@
</svg> </svg>
</button> </button>
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash'}
<button class="icon-btn" onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
</button> </button>
@@ -726,6 +793,10 @@
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'} class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
ontouchstart={(e) => { if (isMobile) handleTouchStart(e, note); }}
ontouchmove={(e) => { if (isMobile) handleTouchMove(e); }}
ontouchend={() => { if (isMobile) handleTouchEnd(); }}
ontouchcancel={() => { if (isMobile) handleTouchEnd(); }}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
const pos = clampMenu(e.clientX, e.clientY); const pos = clampMenu(e.clientX, e.clientY);
@@ -775,6 +846,13 @@
}} }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
> >
{#if isMobile && selectedPaths.size > 0}
<div class="mobile-select-check" class:checked={selectedPaths.has(note.path)}>
{#if selectedPaths.has(note.path)}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
{/if}
</div>
{/if}
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
<span class="note-title"> <span class="note-title">
@@ -995,6 +1073,14 @@
</svg> </svg>
Move to... Move to...
</button> </button>
{#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
Print / Export to PDF
</button>
{/if}
<button class="danger" onclick={() => handleDelete(contextMenu!.note)}> <button class="danger" onclick={() => handleDelete(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/> <path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
@@ -1066,6 +1152,7 @@
.list-actions { .list-actions {
display: flex; display: flex;
align-items: center;
gap: 2px; gap: 2px;
} }
@@ -1566,6 +1653,10 @@
padding: 12px 16px; padding: 12px 16px;
} }
.note-list.mobile .list-actions {
gap: 10px;
}
.note-list.mobile .list-title { .note-list.mobile .list-title {
font-size: 16px; font-size: 16px;
} }
@@ -1684,4 +1775,43 @@
font-size: 15px; font-size: 15px;
min-height: 44px; min-height: 44px;
} }
/* Mobile selection checkboxes */
.mobile-select-check {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: 4px;
transition: all 0.15s ease;
}
.mobile-select-check.checked {
background: var(--accent);
border-color: var(--accent);
color: white;
}
/* Mobile create note button (round, accent-colored) */
.mobile-create-btn {
background: var(--accent);
color: white;
border: none;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.mobile-create-btn:active {
opacity: 0.8;
}
</style> </style>
+65 -23
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import '../app.css'; import '../app.css';
import { theme, appConfig, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { openFile, getInstallType } from '$lib/api'; import { openFile, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
let { children } = $props(); let { children } = $props();
@@ -33,6 +33,35 @@
return resolved.join('/'); return resolved.join('/');
} }
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
// Internal .md note link — navigate within the app
if (absPath.endsWith('.md')) {
readNote(absPath).then((content) => {
activeNote.set({ ...content, content: content.content });
activeNotePath.set(absPath);
}).catch((err) => console.error('Failed to navigate to note:', err));
} else {
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
}
}
// Detect install type and check for updates on startup // Detect install type and check for updates on startup
onMount(() => { onMount(() => {
if (isMobile) { if (isMobile) {
@@ -51,33 +80,46 @@
if (!target) return; if (!target) return;
const href = target.getAttribute('href'); const href = target.getAttribute('href');
if (!href) return; if (!href) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
resolveAndHandleLink(href);
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
const config = get(appConfig);
const vaultRoot = config?.active_vault;
let absPath = decoded;
if (!decoded.startsWith('/') && vaultRoot) {
const notePath = get(activeNotePath);
if (notePath) {
const noteDir = notePath.substring(0, notePath.lastIndexOf('/'));
absPath = normalizePath(`${noteDir}/${decoded}`);
} else {
absPath = normalizePath(`${vaultRoot}/${decoded}`);
}
}
openFile(absPath).catch((err) => console.error('Failed to open file:', err));
}
} }
document.addEventListener('click', handleLinkClick, true); document.addEventListener('click', handleLinkClick, true);
return () => document.removeEventListener('click', handleLinkClick, true);
// On Android WebView, tapping a link can trigger native navigation before
// the JS click event fires (causing 404s for relative .md links).
// We intercept touchend: if it's a tap (not a scroll) on an <a>, we
// preventDefault to block the native navigation and handle it ourselves.
let touchStartY = 0;
function handleTouchStart(e: TouchEvent) {
touchStartY = e.touches[0]?.clientY ?? 0;
}
function handleTouchEnd(e: TouchEvent) {
const endY = e.changedTouches[0]?.clientY ?? 0;
// If the finger moved > 10px, it's a scroll, not a tap
if (Math.abs(endY - touchStartY) > 10) return;
const target = (e.target as HTMLElement)?.closest('a');
if (!target) return;
const href = target.getAttribute('href');
if (!href) return;
e.preventDefault();
resolveAndHandleLink(href);
}
if (isMobile) {
document.addEventListener('touchstart', handleTouchStart, { capture: true, passive: true });
document.addEventListener('touchend', handleTouchEnd, true);
}
return () => {
document.removeEventListener('click', handleLinkClick, true);
if (isMobile) {
document.removeEventListener('touchstart', handleTouchStart, true);
document.removeEventListener('touchend', handleTouchEnd, true);
}
};
}); });
</script> </script>