v1.0.1 - Toolbar hotkeys, graph view improvements, close to tray, AI version snapshots, window resize fix

This commit is contained in:
Yuri Karamian
2026-02-09 23:13:57 +01:00
parent d6caee38b2
commit c105b83570
14 changed files with 226 additions and 76 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl
| Platform | Download | | Platform | Download |
|----------|----------| |----------|----------|
| Linux | [HelixNotes_1.0.0_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.0/HelixNotes_1.0.0_amd64.AppImage) | | Linux | [HelixNotes_1.0.1_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_amd64.AppImage) |
| Windows | Coming soon | | Windows | [HelixNotes_1.0.1_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.1/HelixNotes_1.0.1_x64-setup.exe) |
| macOS | Coming soon | | macOS | Coming soon |
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases) See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
+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.0.0", "version": "1.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+1 -1
View File
@@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.0.0" version = "1.0.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.0.0" version = "1.0.1"
description = "Local-first markdown note-taking app" description = "Local-first markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
+2
View File
@@ -391,6 +391,7 @@ pub fn set_general_settings(
hide_title_in_body: bool, hide_title_in_body: bool,
default_view_mode: bool, default_view_mode: bool,
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool, enable_wiki_links: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
@@ -404,6 +405,7 @@ pub fn set_general_settings(
config.hide_title_in_body = hide_title_in_body; config.hide_title_in_body = hide_title_in_body;
config.default_view_mode = default_view_mode; config.default_view_mode = default_view_mode;
config.show_tray_icon = show_tray_icon; config.show_tray_icon = show_tray_icon;
config.close_to_tray = close_to_tray;
config.enable_wiki_links = enable_wiki_links; config.enable_wiki_links = enable_wiki_links;
save_app_config(&config)?; save_app_config(&config)?;
Ok(()) Ok(())
+14 -2
View File
@@ -19,9 +19,10 @@ use tauri::{
pub fn run() { pub fn run() {
let config = commands::load_app_config(); let config = commands::load_app_config();
let show_tray = config.show_tray_icon; let show_tray = config.show_tray_icon;
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config); let app_state = AppState::new(config);
tauri::Builder::default() let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
@@ -93,7 +94,18 @@ pub fn run() {
commands::set_ai_settings, commands::set_ai_settings,
commands::test_ai_connection, commands::test_ai_connection,
commands::ai_ask, commands::ai_ask,
]) ]);
if close_to_tray {
builder = builder.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
});
}
builder
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }
+3
View File
@@ -97,6 +97,8 @@ pub struct AppConfig {
pub default_view_mode: bool, pub default_view_mode: bool,
#[serde(default)] #[serde(default)]
pub show_tray_icon: bool, pub show_tray_icon: bool,
#[serde(default)]
pub close_to_tray: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enable_wiki_links: bool, pub enable_wiki_links: bool,
} }
@@ -160,6 +162,7 @@ impl Default for AppConfig {
ai_writing_style: None, ai_writing_style: None,
default_view_mode: false, default_view_mode: false,
show_tray_icon: false, show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true, enable_wiki_links: true,
} }
} }
+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.0.0", "version": "1.0.1",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+2
View File
@@ -182,6 +182,7 @@ export async function setGeneralSettings(
hideTitleInBody: boolean, hideTitleInBody: boolean,
defaultViewMode: boolean, defaultViewMode: boolean,
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean, enableWikiLinks: boolean,
): Promise<void> { ): Promise<void> {
return invoke("set_general_settings", { return invoke("set_general_settings", {
@@ -195,6 +196,7 @@ export async function setGeneralSettings(
hideTitleInBody, hideTitleInBody,
defaultViewMode, defaultViewMode,
showTrayIcon, showTrayIcon,
closeToTray,
enableWikiLinks, enableWikiLinks,
}); });
} }
+19 -10
View File
@@ -1875,8 +1875,17 @@
} }
} }
function aiApplyResult() { async function aiApplyResult() {
if (!editor || !aiResult) return; if (!editor || !aiResult) return;
// Save a version snapshot before applying AI changes
if ($activeNotePath && $activeNote && !aiEmptyNote) {
try {
await forceSave();
await createVersion($activeNotePath, $activeNote.meta.id);
} catch (e) {
console.error('Failed to create version before AI apply:', e);
}
}
if (aiEmptyNote) { if (aiEmptyNote) {
// Parse title from first line, rest is content // Parse title from first line, rest is content
const lines = aiResult.split('\n'); const lines = aiResult.split('\n');
@@ -2537,7 +2546,7 @@
<button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline (Ctrl+U)"> <button class="fmt-btn" class:active={(editorState, editor.isActive('underline'))} onclick={() => editor?.chain().focus().toggleUnderline().run()} title="Underline (Ctrl+U)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
</button> </button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough"> <button class="fmt-btn" class:active={(editorState, editor.isActive('strike'))} onclick={() => editor?.chain().focus().toggleStrike().run()} title="Strikethrough (Ctrl+Shift+X)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-3 3 3 3 0 003 3h6"/><line x1="4" y1="12" x2="20" y2="12"/><path d="M8 20h7a3 3 0 003-3 3 3 0 00-3-3H8"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 00-3 3 3 3 0 003 3h6"/><line x1="4" y1="12" x2="20" y2="12"/><path d="M8 20h7a3 3 0 003-3 3 3 0 00-3-3H8"/></svg>
</button> </button>
@@ -2564,20 +2573,20 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Link --> <!-- Link -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link"> <button class="fmt-btn" class:active={(editorState, editor.isActive('link'))} onclick={addLinkFromToolbar} title="Link (Ctrl+K)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button> </button>
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Lists --> <!-- Lists -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List"> <button class="fmt-btn" class:active={(editorState, editor.isActive('bulletList'))} onclick={() => editor?.chain().focus().toggleBulletList().run()} title="Bullet List (Ctrl+Shift+8)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg>
</button> </button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Ordered List"> <button class="fmt-btn" class:active={(editorState, editor.isActive('orderedList'))} onclick={() => editor?.chain().focus().toggleOrderedList().run()} title="Ordered List (Ctrl+Shift+7)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="8" font-size="8" fill="currentColor" stroke="none" font-weight="600">1</text><text x="1" y="14" font-size="8" fill="currentColor" stroke="none" font-weight="600">2</text><text x="1" y="20" font-size="8" fill="currentColor" stroke="none" font-weight="600">3</text></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="1" y="8" font-size="8" fill="currentColor" stroke="none" font-weight="600">1</text><text x="1" y="14" font-size="8" fill="currentColor" stroke="none" font-weight="600">2</text><text x="1" y="20" font-size="8" fill="currentColor" stroke="none" font-weight="600">3</text></svg>
</button> </button>
<button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List"> <button class="fmt-btn" class:active={(editorState, editor.isActive('taskList'))} onclick={() => editor?.chain().focus().toggleTaskList().run()} title="Task List (Ctrl+Shift+9)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg>
</button> </button>
@@ -2594,15 +2603,15 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Code & Code Block --> <!-- Code & Code Block -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title="Inline Code"> <button class="fmt-btn" class:active={(editorState, editor.isActive('code'))} onclick={() => editor?.chain().focus().toggleCode().run()} title="Inline Code (Ctrl+E)">
<svg width="16" height="16" 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> <svg width="16" height="16" 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>
<button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title="Code Block"> <button class="fmt-btn" class:active={(editorState, editor.isActive('codeBlock'))} onclick={() => editor?.chain().focus().toggleCodeBlock().run()} title="Code Block (Ctrl+Alt+C)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16"/><polyline points="15 8 19 12 15 16"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="9 8 5 12 9 16"/><polyline points="15 8 19 12 15 16"/></svg>
</button> </button>
<!-- Blockquote --> <!-- Blockquote -->
<button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title="Quote"> <button class="fmt-btn" class:active={(editorState, editor.isActive('blockquote'))} onclick={() => editor?.chain().focus().toggleBlockquote().run()} title="Quote (Ctrl+Shift+B)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 6h4v4l-2 6H3l2-6H3V6zm10 0h4v4l-2 6h-2l2-6h-2V6z"/></svg>
</button> </button>
@@ -2648,7 +2657,7 @@
<!-- Highlight --> <!-- Highlight -->
<div class="fmt-dropdown-wrap"> <div class="fmt-dropdown-wrap">
<button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Highlight"> <button class="fmt-btn" class:active={(editorState, editor.isActive('highlight'))} onclick={(e) => { e.stopPropagation(); highlightDropdown = !highlightDropdown; headingDropdown = false; colorDropdown = false; tablePickerOpen = false; alignDropdown = false; insertDropdown = false; }} title="Highlight (Ctrl+Shift+H)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/><path d="M15 5l3 3"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/><path d="M15 5l3 3"/></svg>
<span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span> <span class="color-indicator" style="background: {editor.getAttributes('highlight').color || 'var(--accent)'}"></span>
</button> </button>
+151 -54
View File
@@ -20,16 +20,17 @@
y: number; y: number;
vx: number; vx: number;
vy: number; vy: number;
links: string[]; // titles this note links to
} }
interface GraphEdge { interface GraphEdge {
source: string; sourceIdx: number;
target: string; targetIdx: number;
} }
let nodes: GraphNode[] = []; let nodes: GraphNode[] = [];
let edges: GraphEdge[] = []; let edges: GraphEdge[] = [];
let nodeIndexMap: Map<string, number> = new Map();
let connectedSet: Set<number> = new Set();
let animFrame = 0; let animFrame = 0;
let pan = { x: 0, y: 0 }; let pan = { x: 0, y: 0 };
let zoom = 1; let zoom = 1;
@@ -39,6 +40,7 @@
let panning = false; let panning = false;
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;
const wikiLinkRegex = /\[\[([^\]]+)\]\]/g; const wikiLinkRegex = /\[\[([^\]]+)\]\]/g;
@@ -54,7 +56,7 @@
// Create nodes // 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, i) => ({ nodes = titles.map((t) => ({
id: t.title.toLowerCase(), id: t.title.toLowerCase(),
title: t.title, title: t.title,
path: t.path, path: t.path,
@@ -62,33 +64,45 @@
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,
links: [],
})); }));
const nodeMap = new Map<string, GraphNode>(); // Build index map for O(1) lookups
for (const n of nodes) nodeMap.set(n.id, n); nodeIndexMap = new Map();
for (let i = 0; i < nodes.length; i++) {
nodeIndexMap.set(nodes[i].id, i);
}
// Read each note to extract [[wiki-links]] // Read all notes in parallel (batched) to extract [[wiki-links]]
const edgeSet = new Set<string>(); const edgeSet = new Set<string>();
for (const node of nodes) { edges = [];
try { 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); const content = await readNote(node.path);
const body = content.content || ''; 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; let match;
wikiLinkRegex.lastIndex = 0; wikiLinkRegex.lastIndex = 0;
while ((match = wikiLinkRegex.exec(body)) !== null) { while ((match = wikiLinkRegex.exec(body)) !== null) {
const linkTitle = match[1].trim().toLowerCase(); const linkTitle = match[1].trim().toLowerCase();
if (linkTitle !== node.id && nodeMap.has(linkTitle)) { const targetIdx = nodeIndexMap.get(linkTitle);
node.links.push(linkTitle); if (linkTitle !== node.id && targetIdx !== undefined) {
const edgeKey = [node.id, linkTitle].sort().join('|'); const edgeKey = nodeIdx < targetIdx ? `${nodeIdx}|${targetIdx}` : `${targetIdx}|${nodeIdx}`;
if (!edgeSet.has(edgeKey)) { if (!edgeSet.has(edgeKey)) {
edgeSet.add(edgeKey); edgeSet.add(edgeKey);
edges.push({ source: node.id, target: linkTitle }); edges.push({ sourceIdx: nodeIdx, targetIdx });
connectedSet.add(nodeIdx);
connectedSet.add(targetIdx);
} }
} }
} }
} catch {
// Skip notes that can't be read
} }
} }
} catch (e) { } catch (e) {
@@ -98,13 +112,57 @@
startSimulation(); startSimulation();
} }
function centerOnActiveNote() {
if (!canvas || nodes.length === 0) return;
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
// Only center on active note if it has connections
if (!activeNode) return;
const activeIdx = nodeIndexMap.get(activeNode.id);
if (activeIdx === undefined || !connectedSet.has(activeIdx)) return;
// Gather the active node and its direct neighbors
const neighborhood: GraphNode[] = [activeNode];
for (const edge of edges) {
if (edge.sourceIdx === activeIdx) neighborhood.push(nodes[edge.targetIdx]);
else if (edge.targetIdx === activeIdx) neighborhood.push(nodes[edge.sourceIdx]);
}
const w = canvas.width;
const h = canvas.height;
const padding = 80;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of neighborhood) {
if (n.x < minX) minX = n.x;
if (n.y < minY) minY = n.y;
if (n.x > maxX) maxX = n.x;
if (n.y > maxY) maxY = n.y;
}
const graphW = maxX - minX || 1;
const graphH = maxY - minY || 1;
const centerX = (minX + maxX) / 2;
const centerY = (minY + maxY) / 2;
zoom = Math.min(
(w - padding * 2) / graphW,
(h - padding * 2) / graphH,
1.8
);
zoom = Math.max(zoom, 0.5);
pan.x = w / 2 - centerX * zoom;
pan.y = h / 2 - centerY * zoom;
}
function fitToView() { function fitToView() {
if (!canvas || nodes.length === 0) return; if (!canvas || nodes.length === 0) return;
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
const padding = 60; const padding = 60;
// Compute bounding box of all nodes
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const node of nodes) { for (const node of nodes) {
if (node.x < minX) minX = node.x; if (node.x < minX) minX = node.x;
@@ -118,36 +176,49 @@
const centerGraphX = (minX + maxX) / 2; const centerGraphX = (minX + maxX) / 2;
const centerGraphY = (minY + maxY) / 2; const centerGraphY = (minY + maxY) / 2;
// Compute zoom to fit
zoom = Math.min( zoom = Math.min(
(w - padding * 2) / graphW, (w - padding * 2) / graphW,
(h - padding * 2) / graphH, (h - padding * 2) / graphH,
2 // max zoom 2
); );
zoom = Math.max(zoom, 0.2); zoom = Math.max(zoom, 0.2);
// Center the graph
pan.x = w / 2 - centerGraphX * zoom; pan.x = w / 2 - centerGraphX * zoom;
pan.y = h / 2 - centerGraphY * zoom; pan.y = h / 2 - centerGraphY * zoom;
} }
function startSimulation() { function startSimulation() {
if (animFrame) cancelAnimationFrame(animFrame); if (animFrame) cancelAnimationFrame(animFrame);
let iterations = 0;
const maxIterations = 300;
function tick() { // Run physics synchronously — no need to animate the settling
if (iterations >= maxIterations) { for (let i = 0; i < 300; i++) {
fitToView();
draw();
return;
}
simulate(); simulate();
draw();
iterations++;
animFrame = requestAnimationFrame(tick);
} }
animFrame = requestAnimationFrame(tick);
// Center on active note if it has links, otherwise fit all
const activePath = $activeNotePath || '';
const activeNode = nodes.find(n => n.path === activePath);
const activeIdx = activeNode ? nodeIndexMap.get(activeNode.id) : undefined;
if (activeIdx !== undefined && connectedSet.has(activeIdx)) {
centerOnActiveNote();
} else {
fitToView();
}
draw();
startGlowLoop();
}
let glowFrame = 0;
function startGlowLoop() {
if (glowFrame) cancelAnimationFrame(glowFrame);
function loop() {
glowPhase += 0.04;
draw();
glowFrame = requestAnimationFrame(loop);
}
glowFrame = requestAnimationFrame(loop);
} }
function simulate() { function simulate() {
@@ -161,13 +232,14 @@
// Repulsion between all nodes // Repulsion between all nodes
for (let i = 0; i < nodeCount; i++) { for (let i = 0; i < nodeCount; i++) {
for (let j = i + 1; j < nodeCount; j++) {
const a = nodes[i]; const a = nodes[i];
for (let j = i + 1; j < nodeCount; j++) {
const b = nodes[j]; const b = nodes[j];
let dx = b.x - a.x; const dx = b.x - a.x;
let dy = b.y - a.y; const dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1; const distSq = dx * dx + dy * dy || 1;
const force = 800 / (dist * dist); const force = 800 / distSq;
const dist = Math.sqrt(distSq);
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;
@@ -177,14 +249,13 @@
} }
} }
// Attraction along edges // Attraction along edges (indexed lookups)
for (const edge of edges) { for (const edge of edges) {
const a = nodes.find(n => n.id === edge.source); const a = nodes[edge.sourceIdx];
const b = nodes.find(n => n.id === edge.target); const b = nodes[edge.targetIdx];
if (!a || !b) continue; const dx = b.x - a.x;
let dx = b.x - a.x; const dy = b.y - a.y;
let dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy) || 1;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
const force = (dist - 100) * 0.01; const force = (dist - 100) * 0.01;
const fx = (dx / dist) * force; const fx = (dx / dist) * force;
const fy = (dy / dist) * force; const fy = (dy / dist) * force;
@@ -227,16 +298,14 @@
const textColor = style.getPropertyValue('--text-primary').trim() || '#eee'; const textColor = style.getPropertyValue('--text-primary').trim() || '#eee';
const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888'; const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888';
const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4'; const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4';
const accentLight = style.getPropertyValue('--accent-light').trim() || 'rgba(123,155,212,0.15)';
// Draw edges // Draw edges
ctx.strokeStyle = borderColor; ctx.strokeStyle = borderColor;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.globalAlpha = 0.4; ctx.globalAlpha = 0.4;
for (const edge of edges) { for (const edge of edges) {
const a = nodes.find(n => n.id === edge.source); const a = nodes[edge.sourceIdx];
const b = nodes.find(n => n.id === edge.target); const b = nodes[edge.targetIdx];
if (!a || !b) continue;
ctx.beginPath(); 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);
@@ -248,11 +317,27 @@
const activePath = $activeNotePath || ''; const activePath = $activeNotePath || '';
// Draw nodes // Draw nodes
for (const node of nodes) { const pulse = 0.5 + 0.5 * Math.sin(glowPhase);
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const isActive = node.path === activePath; const isActive = node.path === activePath;
const isHovered = node === hoveredNode; const isHovered = node === hoveredNode;
const hasLinks = node.links.length > 0 || edges.some(e => e.source === node.id || e.target === node.id); const hasLinks = connectedSet.has(i);
const radius = isActive ? 7 : hasLinks ? 5 : 3.5; const baseRadius = isActive ? 9 : hasLinks ? 5 : 3.5;
const radius = isActive ? baseRadius + pulse * 2 : baseRadius;
// Active node glow
if (isActive) {
const glowRadius = radius + 10 + pulse * 6;
const glow = ctx.createRadialGradient(node.x, node.y, radius, node.x, node.y, glowRadius);
glow.addColorStop(0, accent + '60');
glow.addColorStop(1, accent + '00');
ctx.beginPath();
ctx.arc(node.x, node.y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
}
// Node circle // Node circle
ctx.beginPath(); ctx.beginPath();
@@ -268,12 +353,23 @@
} }
ctx.fill(); ctx.fill();
// Active node ring
if (isActive) {
ctx.beginPath();
ctx.arc(node.x, node.y, radius + 3, 0, Math.PI * 2);
ctx.strokeStyle = accent;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.4 + pulse * 0.3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Label // Label
if (isActive || isHovered || hasLinks) { if (isActive || isHovered || hasLinks) {
ctx.font = `${isActive || 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;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(node.title, node.x, node.y - radius - 5); ctx.fillText(node.title, node.x, node.y - radius - 6);
} }
} }
@@ -377,6 +473,7 @@
onDestroy(() => { onDestroy(() => {
if (animFrame) cancelAnimationFrame(animFrame); if (animFrame) cancelAnimationFrame(animFrame);
if (glowFrame) cancelAnimationFrame(glowFrame);
}); });
</script> </script>
+5 -1
View File
@@ -2,10 +2,14 @@
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats } from '$lib/api';
import { openUrl } from '@tauri-apps/plugin-opener'; import { openUrl } from '@tauri-apps/plugin-opener';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>('shortcuts'); let activeTab = $state<'about' | 'shortcuts'>('shortcuts');
let appVersion = $state('...');
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
function close() { function close() {
$showInfo = false; $showInfo = false;
@@ -66,7 +70,7 @@
</svg> </svg>
</div> </div>
<h3 class="app-name">HelixNotes</h3> <h3 class="app-name">HelixNotes</h3>
<p class="app-version">v1.0.0</p> <p class="app-version">v{appVersion}</p>
<p class="app-description">A local-first markdown note-taking app.</p> <p class="app-description">A local-first markdown note-taking app.</p>
{#if stats} {#if stats}
+15 -2
View File
@@ -283,6 +283,7 @@
let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false); let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false);
let defaultViewMode = $state($appConfig?.default_view_mode ?? false); let defaultViewMode = $state($appConfig?.default_view_mode ?? false);
let showTrayIcon = $state($appConfig?.show_tray_icon ?? false); let showTrayIcon = $state($appConfig?.show_tray_icon ?? false);
let closeToTray = $state($appConfig?.close_to_tray ?? false);
let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true); let enableWikiLinks = $state($appConfig?.enable_wiki_links ?? true);
const pdfHeightPresets = [ const pdfHeightPresets = [
@@ -334,9 +335,10 @@
$appConfig.hide_title_in_body = hideTitleInBody; $appConfig.hide_title_in_body = hideTitleInBody;
$appConfig.default_view_mode = defaultViewMode; $appConfig.default_view_mode = defaultViewMode;
$appConfig.show_tray_icon = showTrayIcon; $appConfig.show_tray_icon = showTrayIcon;
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $appConfig.enable_wiki_links = enableWikiLinks;
} }
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, enableWikiLinks) setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks)
.catch((e) => console.error('Failed to save general settings:', e)); .catch((e) => console.error('Failed to save general settings:', e));
} }
@@ -560,10 +562,21 @@
<span class="setting-name">Show in system tray</span> <span class="setting-name">Show in system tray</span>
<span class="setting-desc">Show an icon in the notification area (requires restart)</span> <span class="setting-desc">Show an icon in the notification area (requires restart)</span>
</span> </span>
<button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; saveGeneralSettings(); }}> <button class="toggle-switch" class:on={showTrayIcon} onclick={() => { showTrayIcon = !showTrayIcon; if (!showTrayIcon) closeToTray = false; saveGeneralSettings(); }}>
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
{#if showTrayIcon}
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Close to tray</span>
<span class="setting-desc">Minimize to tray instead of quitting when closing the window (requires restart)</span>
</span>
<button class="toggle-switch" class:on={closeToTray} onclick={() => { closeToTray = !closeToTray; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
{/if}
</div> </div>
</div> </div>
{:else if activeTab === 'editor'} {:else if activeTab === 'editor'}
+8
View File
@@ -21,11 +21,19 @@
let lastMouseDown = 0; let lastMouseDown = 0;
const RESIZE_EDGE = 6;
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (e.button !== 0) return; if (e.button !== 0) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return; if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return;
// Don't start dragging near window edges — let Tauri handle resize
if (!maximized) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (e.clientY - rect.top < RESIZE_EDGE || e.clientX - rect.left < RESIZE_EDGE) return;
}
const now = Date.now(); const now = Date.now();
if (now - lastMouseDown < 300) { if (now - lastMouseDown < 300) {
// Double-click detected — maximize/restore // Double-click detected — maximize/restore