diff --git a/package.json b/package.json index c0544d9..04f85c8 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.1.9", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b2d7622..d0e4c2e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -647,6 +647,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1603,6 +1609,16 @@ dependencies = [ "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]] name = "gio" version = "0.18.4" @@ -1833,13 +1849,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.1.9" +version = "1.2.0" dependencies = [ "arboard", "chrono", "dirs", "futures", "gray_matter", + "image", "log", "notify", "png 0.17.16", @@ -2157,10 +2174,25 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.1", "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]] @@ -5406,7 +5438,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -7225,13 +7257,28 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" 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]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 075d565..177fc6e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.1.9" +version = "1.2.0" description = "Local markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" @@ -43,3 +43,4 @@ tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" arboard = { version = "3", features = ["image-data", "wayland-data-control"] } png = "0.17" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 230c7cf..b845a7a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -400,6 +400,128 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result) -> Result { + 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 = HashMap::new(); + let mut seen_paths: HashSet = HashSet::new(); + let mut contents: Vec = 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 { + 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 ── #[tauri::command] @@ -509,6 +631,33 @@ pub fn read_clipboard_image() -> Result, 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 ── #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a7cbfc..0e1e90f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -112,6 +112,7 @@ pub fn run() { commands::move_note, commands::get_all_tags, commands::get_all_note_titles, + commands::get_graph_data, commands::search_notes, commands::reindex, commands::get_trash, @@ -121,6 +122,7 @@ pub fn run() { commands::load_vault_state, commands::save_vault_state, commands::read_clipboard_image, + commands::copy_image_to_clipboard, commands::save_image, commands::save_attachment, commands::get_notebook_icons, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5db5fbd..64daa5c 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -254,3 +254,21 @@ pub struct NoteTitleEntry { pub title: String, pub path: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GraphData { + pub nodes: Vec, + pub edges: Vec, +} + +#[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, +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fcb797d..7c8a87b 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.1.9", + "version": "1.2.0", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index 6f89bc2..b76f328 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -126,6 +126,10 @@ export async function getAllNoteTitles(): Promise { 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( query: string, limit?: number, @@ -168,6 +172,10 @@ export async function readClipboardImage(): Promise { return invoke("read_clipboard_image"); } +export async function copyImageToClipboard(path: string): Promise { + return invoke("copy_image_to_clipboard", { path }); +} + export async function saveImage(name: string, data: number[]): Promise { return invoke("save_image", { name, data }); } diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index b6e77cc..d8564b2 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -467,11 +467,6 @@ {/if} - + {#if $appConfig?.enable_wiki_links} + + {/if} {#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} {/if} + {:else} + + {/if} {/if} +{#if copyToast} +
+ {#if copyToast === 'copying'} + + + + + Copying... + {:else} + + + + Copied + {/if} +
+{/if} + {#if codeLangDropdown}
@@ -5544,8 +5672,8 @@ .img-toolbar { position: fixed; - transform: translateX(-50%) translateY(-100%); display: flex; + align-items: center; gap: 2px; background: var(--bg-primary); border: 1px solid var(--border-color); @@ -5576,6 +5704,54 @@ 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) { padding: 0px 5px 2px; border-radius: 3px; diff --git a/src/lib/components/GraphView.svelte b/src/lib/components/GraphView.svelte index 5624fce..c5ab1cc 100644 --- a/src/lib/components/GraphView.svelte +++ b/src/lib/components/GraphView.svelte @@ -1,8 +1,7 @@ @@ -516,6 +579,9 @@ onmousemove={handleMouseMove} onmouseup={handleMouseUp} onwheel={handleWheel} + ontouchstart={handleTouchStart} + ontouchmove={handleTouchMove} + ontouchend={handleTouchEnd} >
@@ -555,6 +621,20 @@ 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 { font-size: 15px; font-weight: 600; @@ -594,6 +674,7 @@ width: 100%; height: 100%; cursor: grab; + touch-action: none; } .graph-canvas:active { @@ -604,21 +685,19 @@ position: absolute; inset: 0; display: flex; - flex-direction: column; align-items: center; justify-content: center; - gap: 12px; - color: var(--text-tertiary); + gap: 10px; font-size: 13px; + color: var(--text-tertiary); z-index: 1; } .spinner { - animation: spin 0.8s linear infinite; + animation: spin 1s linear infinite; } @keyframes spin { - from { transform: rotate(0deg); } to { transform: rotate(360deg); } }