Compare commits

...
2 Commits
12 changed files with 637 additions and 140 deletions
+10 -6
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.1.9)
### 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.1.9/HelixNotes_1.1.9_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.1.9/HelixNotes_1.1.9_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.1.9/HelixNotes_1.1.9_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.1.9/HelixNotes_1.1.9_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://download.helixnotes.com/releases/v1.1.9/HelixNotes_1.1.9_android.apk) — Tested on Android 16
--- ---
@@ -70,7 +74,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
+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.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+50 -3
View File
@@ -647,6 +647,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 +1609,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 +1849,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.1.9" version = "1.2.0"
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",
@@ -2157,10 +2174,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]]
@@ -5406,7 +5438,7 @@ dependencies = [
"half", "half",
"quick-error", "quick-error",
"weezl", "weezl",
"zune-jpeg", "zune-jpeg 0.4.21",
] ]
[[package]] [[package]]
@@ -7225,13 +7257,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]]
+2 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.1.9" version = "1.2.0"
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"
@@ -43,3 +43,4 @@ tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "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"] }
+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]
+2
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,
+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,
}
+1 -1
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.0",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+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 });
} }
+21 -8
View File
@@ -467,11 +467,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 +498,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 +513,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">
@@ -780,15 +788,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);
} }
+198 -22
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';
@@ -203,7 +203,8 @@
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 +591,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 +616,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 +629,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 +734,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 +804,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 +891,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 +943,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 +953,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 +972,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;
} }
@@ -2156,9 +2188,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,6 +2222,10 @@
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;
@@ -2250,12 +2286,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 +2300,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 +2337,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');
@@ -4189,10 +4291,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}>
@@ -5544,8 +5672,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 +5704,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;
+172 -93
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(), // Build node index map
title: t.title, nodeIndexMap = new Map();
path: t.path, nodes = data.nodes.map((n, 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, 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, y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6,
vx: 0, vx: 0,
vy: 0, vy: 0,
})); };
});
// Build index map for O(1) lookups // Map edges and track connected nodes
nodeIndexMap = new Map(); connectedSet = new Set();
for (let i = 0; i < nodes.length; i++) { edges = data.edges.map(e => {
nodeIndexMap.set(nodes[i].id, i); connectedSet.add(e.source);
} connectedSet.add(e.target);
return { sourceIdx: e.source, targetIdx: e.target };
// Read all notes in parallel (batched) to extract [[wiki-links]] });
const edgeSet = new Set<string>();
edges = [];
const BATCH_SIZE = 20;
for (let b = 0; b < nodes.length; b += BATCH_SIZE) {
const batch = nodes.slice(b, b + BATCH_SIZE);
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,18 +101,18 @@
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];
if (activeIdx !== undefined) {
for (const edge of edges) { for (const edge of edges) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]); if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]); else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
} }
}
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
@@ -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>