From 70708242d5a3ae85cf96e52af482c03107b56d34 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Mon, 9 Feb 2026 17:25:15 +0100 Subject: [PATCH] v1.0.1 - Toolbar hotkeys, graph view improvements, close to tray, AI version snapshots, window resize fix --- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/commands.rs | 2 + src-tauri/src/lib.rs | 16 +- src-tauri/src/types.rs | 3 + src-tauri/tauri.conf.json | 2 +- src/lib/api.ts | 2 + src/lib/components/Editor.svelte | 29 ++-- src/lib/components/GraphView.svelte | 207 +++++++++++++++++------- src/lib/components/SettingsPanel.svelte | 17 +- src/lib/components/TitleBar.svelte | 8 + 12 files changed, 219 insertions(+), 73 deletions(-) diff --git a/package.json b/package.json index ca719c6..2cd2887 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.0.0", + "version": "1.0.1", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2377bef..7c72845 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.0.0" +version = "1.0.1" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 912556e..dd7be63 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.0.0" +version = "1.0.1" description = "Local-first markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1142a3f..aacce3d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -391,6 +391,7 @@ pub fn set_general_settings( hide_title_in_body: bool, default_view_mode: bool, show_tray_icon: bool, + close_to_tray: bool, enable_wiki_links: bool, ) -> Result<(), 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.default_view_mode = default_view_mode; config.show_tray_icon = show_tray_icon; + config.close_to_tray = close_to_tray; config.enable_wiki_links = enable_wiki_links; save_app_config(&config)?; Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 125f2f9..d620592 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,9 +19,10 @@ use tauri::{ pub fn run() { let config = commands::load_app_config(); let show_tray = config.show_tray_icon; + let close_to_tray = config.close_to_tray && show_tray; let app_state = AppState::new(config); - tauri::Builder::default() + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) @@ -93,7 +94,18 @@ pub fn run() { commands::set_ai_settings, commands::test_ai_connection, 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!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5f7eb24..85df552 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -97,6 +97,8 @@ pub struct AppConfig { pub default_view_mode: bool, #[serde(default)] pub show_tray_icon: bool, + #[serde(default)] + pub close_to_tray: bool, #[serde(default = "default_true")] pub enable_wiki_links: bool, } @@ -160,6 +162,7 @@ impl Default for AppConfig { ai_writing_style: None, default_view_mode: false, show_tray_icon: false, + close_to_tray: false, enable_wiki_links: true, } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2aa09ed..5c1b776 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "HelixNotes", - "version": "1.0.0", + "version": "1.0.1", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index ba03ed1..3ba8689 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -182,6 +182,7 @@ export async function setGeneralSettings( hideTitleInBody: boolean, defaultViewMode: boolean, showTrayIcon: boolean, + closeToTray: boolean, enableWikiLinks: boolean, ): Promise { return invoke("set_general_settings", { @@ -195,6 +196,7 @@ export async function setGeneralSettings( hideTitleInBody, defaultViewMode, showTrayIcon, + closeToTray, enableWikiLinks, }); } diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index b71edff..9fdc561 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -1875,8 +1875,17 @@ } } - function aiApplyResult() { + async function aiApplyResult() { 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) { // Parse title from first line, rest is content const lines = aiResult.split('\n'); @@ -2537,7 +2546,7 @@ - @@ -2564,20 +2573,20 @@
-
- - - @@ -2594,15 +2603,15 @@
- - - @@ -2648,7 +2657,7 @@
- diff --git a/src/lib/components/GraphView.svelte b/src/lib/components/GraphView.svelte index daa794b..2f96453 100644 --- a/src/lib/components/GraphView.svelte +++ b/src/lib/components/GraphView.svelte @@ -20,16 +20,17 @@ y: number; vx: number; vy: number; - links: string[]; // titles this note links to } interface GraphEdge { - source: string; - target: string; + sourceIdx: number; + targetIdx: number; } let nodes: GraphNode[] = []; let edges: GraphEdge[] = []; + let nodeIndexMap: Map = new Map(); + let connectedSet: Set = new Set(); let animFrame = 0; let pan = { x: 0, y: 0 }; let zoom = 1; @@ -39,6 +40,7 @@ let panning = false; let panStart = { x: 0, y: 0 }; let hoveredNode: GraphNode | null = null; + let glowPhase = 0; const wikiLinkRegex = /\[\[([^\]]+)\]\]/g; @@ -54,7 +56,7 @@ // Create nodes const w = canvas?.width ?? 800; const h = canvas?.height ?? 600; - nodes = titles.map((t, i) => ({ + nodes = titles.map((t) => ({ id: t.title.toLowerCase(), title: t.title, path: t.path, @@ -62,33 +64,45 @@ y: h / 2 + (Math.random() - 0.5) * Math.min(w, h) * 0.6, vx: 0, vy: 0, - links: [], })); - const nodeMap = new Map(); - for (const n of nodes) nodeMap.set(n.id, n); + // Build index map for O(1) lookups + 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(); - for (const node of nodes) { - try { - const content = await readNote(node.path); - const body = content.content || ''; + 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) { const linkTitle = match[1].trim().toLowerCase(); - if (linkTitle !== node.id && nodeMap.has(linkTitle)) { - node.links.push(linkTitle); - const edgeKey = [node.id, linkTitle].sort().join('|'); + 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({ 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) { @@ -98,13 +112,57 @@ 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() { if (!canvas || nodes.length === 0) return; const w = canvas.width; const h = canvas.height; const padding = 60; - // Compute bounding box of all nodes let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const node of nodes) { if (node.x < minX) minX = node.x; @@ -118,36 +176,49 @@ const centerGraphX = (minX + maxX) / 2; const centerGraphY = (minY + maxY) / 2; - // Compute zoom to fit zoom = Math.min( (w - padding * 2) / graphW, (h - padding * 2) / graphH, - 2 // max zoom + 2 ); zoom = Math.max(zoom, 0.2); - // Center the graph pan.x = w / 2 - centerGraphX * zoom; pan.y = h / 2 - centerGraphY * zoom; } function startSimulation() { if (animFrame) cancelAnimationFrame(animFrame); - let iterations = 0; - const maxIterations = 300; - function tick() { - if (iterations >= maxIterations) { - fitToView(); - draw(); - return; - } + // Run physics synchronously — no need to animate the settling + for (let i = 0; i < 300; i++) { 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() { @@ -161,13 +232,14 @@ // Repulsion between all nodes for (let i = 0; i < nodeCount; i++) { + const a = nodes[i]; for (let j = i + 1; j < nodeCount; j++) { - const a = nodes[i]; const b = nodes[j]; - let dx = b.x - a.x; - let dy = b.y - a.y; - let dist = Math.sqrt(dx * dx + dy * dy) || 1; - const force = 800 / (dist * dist); + const dx = b.x - a.x; + const dy = b.y - a.y; + const distSq = dx * dx + dy * dy || 1; + const force = 800 / distSq; + const dist = Math.sqrt(distSq); const fx = (dx / dist) * force; const fy = (dy / dist) * force; a.vx -= fx; @@ -177,14 +249,13 @@ } } - // Attraction along edges + // Attraction along edges (indexed lookups) for (const edge of edges) { - const a = nodes.find(n => n.id === edge.source); - const b = nodes.find(n => n.id === edge.target); - if (!a || !b) continue; - let dx = b.x - a.x; - let dy = b.y - a.y; - let dist = Math.sqrt(dx * dx + dy * dy) || 1; + const a = nodes[edge.sourceIdx]; + const b = nodes[edge.targetIdx]; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; const force = (dist - 100) * 0.01; const fx = (dx / dist) * force; const fy = (dy / dist) * force; @@ -227,16 +298,14 @@ const textColor = style.getPropertyValue('--text-primary').trim() || '#eee'; const textSecondary = style.getPropertyValue('--text-tertiary').trim() || '#888'; const accent = style.getPropertyValue('--accent').trim() || '#7b9bd4'; - const accentLight = style.getPropertyValue('--accent-light').trim() || 'rgba(123,155,212,0.15)'; // Draw edges ctx.strokeStyle = borderColor; ctx.lineWidth = 1; ctx.globalAlpha = 0.4; for (const edge of edges) { - const a = nodes.find(n => n.id === edge.source); - const b = nodes.find(n => n.id === edge.target); - if (!a || !b) continue; + const a = nodes[edge.sourceIdx]; + const b = nodes[edge.targetIdx]; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); @@ -248,11 +317,27 @@ const activePath = $activeNotePath || ''; // 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 isHovered = node === hoveredNode; - const hasLinks = node.links.length > 0 || edges.some(e => e.source === node.id || e.target === node.id); - const radius = isActive ? 7 : hasLinks ? 5 : 3.5; + const hasLinks = connectedSet.has(i); + 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 ctx.beginPath(); @@ -268,12 +353,23 @@ } 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 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.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(() => { if (animFrame) cancelAnimationFrame(animFrame); + if (glowFrame) cancelAnimationFrame(glowFrame); }); diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 5f401d1..69c0ece 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -283,6 +283,7 @@ let hideTitleInBody = $state($appConfig?.hide_title_in_body ?? false); let defaultViewMode = $state($appConfig?.default_view_mode ?? 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); const pdfHeightPresets = [ @@ -334,9 +335,10 @@ $appConfig.hide_title_in_body = hideTitleInBody; $appConfig.default_view_mode = defaultViewMode; $appConfig.show_tray_icon = showTrayIcon; + $appConfig.close_to_tray = closeToTray; $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)); } @@ -560,10 +562,21 @@ Show in system tray Show an icon in the notification area (requires restart) - + {#if showTrayIcon} + + {/if}
{:else if activeTab === 'editor'} diff --git a/src/lib/components/TitleBar.svelte b/src/lib/components/TitleBar.svelte index 3cfb2ae..b7ae980 100644 --- a/src/lib/components/TitleBar.svelte +++ b/src/lib/components/TitleBar.svelte @@ -21,11 +21,19 @@ let lastMouseDown = 0; + const RESIZE_EDGE = 6; + function handleMouseDown(e: MouseEvent) { if (e.button !== 0) return; const target = e.target as HTMLElement; if (target.closest('.titlebar-controls') || target.closest('.titlebar-actions')) return; + // 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(); if (now - lastMouseDown < 300) { // Double-click detected — maximize/restore