diff --git a/README.md b/README.md index 1039e7d..0d8b53f 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ Your notes are stored as standard Markdown files on your local filesystem. No cl | Platform | Download | Notes | |----------|----------|-------| -| Linux (Arch/rolling) | [HelixNotes_1.0.6_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_amd64.AppImage) | Best for Arch, Fedora, openSUSE | -| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.6_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_amd64.deb) | Ubuntu 22.04+ | -| Windows | [HelixNotes_1.0.6_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.6/HelixNotes_1.0.6_x64-setup.exe) | Windows 10/11 | +| Linux (Arch/rolling) | [HelixNotes_1.0.9_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.AppImage) | Best for Arch, Fedora, openSUSE | +| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.9_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.deb) | Ubuntu 22.04+ | +| Windows | [HelixNotes_1.0.9_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_x64-setup.exe) | Windows 10/11 | | macOS | Coming soon | | See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases) @@ -25,6 +25,7 @@ See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.or - **Version history** — per-note snapshots with diff view - **Backups** — automatic zip-based vault backups - **PDF preview** — inline rendering of embedded PDFs +- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging - **Obsidian import** — convert Obsidian wiki-links to standard markdown - **Themes** — light/dark mode with customizable accent colors and fonts - **Focus mode** — distraction-free writing diff --git a/package.json b/package.json index 1f343cd..8ff81bd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.0.8", + "version": "1.0.9", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6e3840a..a86555b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.0.8" +version = "1.0.9" dependencies = [ "chrono", "dirs", @@ -1799,6 +1799,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", "uuid", @@ -5081,6 +5082,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc61e4822b8f74d68278e09161d3e3fdd1b14b9eb781e24edccaabf10c420e8c" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index eb7236e..f8d518c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.0.8" +version = "1.0.9" description = "Local-first markdown note-taking app" authors = ["HelixNotes"] license = "AGPL-3.0-or-later" @@ -38,3 +38,4 @@ zip = { version = "2", default-features = false, features = ["deflate"] } reqwest = { version = "0.12", features = ["json", "stream"] } futures = "0.3" rayon = "1" +tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index d017435..388415d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -102,6 +102,77 @@ pub fn rename_notebook(path: String, new_name: String) -> Result operations::rename_notebook(&path, &new_name) } +#[tauri::command] +pub fn move_notebook( + state: State<'_, AppState>, + notebook_path: String, + dest_parent: String, +) -> Result { + 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 old_relative = Path::new(¬ebook_path) + .strip_prefix(vault_path.as_str()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let new_full_path = operations::move_notebook(¬ebook_path, &dest_parent)?; + + let new_relative = Path::new(&new_full_path) + .strip_prefix(vault_path.as_str()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Update Quick Access paths for all notes inside the moved notebook + if let Ok(mut qa) = operations::load_quick_access(vault_path) { + let old_prefix = format!("{}/", old_relative); + let mut changed = false; + for path in qa.iter_mut() { + if path.starts_with(&old_prefix) { + *path = format!("{}/{}", new_relative, &path[old_prefix.len()..]); + changed = true; + } + } + if changed { + let _ = operations::save_quick_access(vault_path, &qa); + } + } + + // Update notebook icon mappings + if let Ok(icons) = operations::load_notebook_icons(vault_path) { + let old_prefix = format!("{}/", old_relative); + let mut new_icons = std::collections::HashMap::new(); + let mut changed = false; + for (key, value) in &icons { + if *key == old_relative { + new_icons.insert(new_relative.clone(), value.clone()); + changed = true; + } else if key.starts_with(&old_prefix) { + let new_key = format!("{}/{}", new_relative, &key[old_prefix.len()..]); + new_icons.insert(new_key, value.clone()); + changed = true; + } else { + new_icons.insert(key.clone(), value.clone()); + } + } + if changed { + let icons_path = operations::helixnotes_dir(vault_path).join("notebook_icons.json"); + if let Ok(data) = serde_json::to_string_pretty(&new_icons) { + let _ = std::fs::write(&icons_path, data); + } + } + } + + // Rebuild search index (paths changed for all contained notes) + if let Ok(search_guard) = state.search_index.lock() { + if let Some(ref search) = *search_guard { + let _ = search.rebuild(vault_path); + } + } + + Ok(new_full_path) +} + #[tauri::command] pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> { let config = state.config.lock().map_err(|e| e.to_string())?; @@ -1385,12 +1456,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> { #[tauri::command] pub fn get_install_type() -> String { - if cfg!(target_os = "windows") { + if cfg!(target_os = "macos") { + "macos".to_string() + } else if cfg!(target_os = "windows") { "windows".to_string() } else if std::env::var("APPIMAGE").is_ok() { "appimage".to_string() } else if std::path::Path::new("/var/lib/dpkg/info/helix-notes.list").exists() { "deb".to_string() + } else if std::path::Path::new("/var/lib/pacman/local").exists() + && std::process::Command::new("pacman") + .args(["-Q", "helixnotes"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + "aur".to_string() } else { "native".to_string() } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8e94cfd..4b13040 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,13 @@ pub fn run() { let app_state = AppState::new(config); let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } + })) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) @@ -54,6 +61,7 @@ pub fn run() { commands::create_notebook, commands::rename_notebook, commands::delete_notebook, + commands::move_notebook, commands::get_notes, commands::read_note, commands::save_note, diff --git a/src-tauri/src/vault/operations.rs b/src-tauri/src/vault/operations.rs index 817a411..d3ad4ab 100644 --- a/src-tauri/src/vault/operations.rs +++ b/src-tauri/src/vault/operations.rs @@ -432,6 +432,38 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result Ok(dest.to_string_lossy().to_string()) } +pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result { + let src = Path::new(notebook_path); + if !src.exists() || !src.is_dir() { + return Err("Notebook does not exist".to_string()); + } + + let dest_parent_path = Path::new(dest_parent); + if !dest_parent_path.is_dir() { + return Err("Destination does not exist".to_string()); + } + + // No-op if already in that parent + if src.parent() == Some(dest_parent_path) { + return Err("Notebook is already in that location".to_string()); + } + + let dir_name = src.file_name().unwrap_or_default(); + let dest = dest_parent_path.join(dir_name); + + // Prevent moving into itself or a descendant + if dest.starts_with(src) { + return Err("Cannot move a notebook into itself or its descendants".to_string()); + } + + if dest.exists() { + return Err("A notebook with that name already exists in the destination".to_string()); + } + + fs::rename(src, &dest).map_err(|e| e.to_string())?; + Ok(dest.to_string_lossy().to_string()) +} + pub fn get_trash_notes(vault_path: &str) -> Result, String> { let trash_dir = helixnotes_dir(vault_path).join("trash"); if !trash_dir.exists() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bafbbd1..b834678 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.8", + "version": "1.0.9", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/lib/api.ts b/src/lib/api.ts index feb0ab8..869c753 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -60,6 +60,13 @@ export async function deleteNotebook(path: string): Promise { return invoke("delete_notebook", { path }); } +export async function moveNotebook( + notebookPath: string, + destParent: string, +): Promise { + return invoke("move_notebook", { notebookPath, destParent }); +} + export async function getNotes( notebookPath: string | null, ): Promise { diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index f093865..c2dfa97 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -75,6 +75,7 @@ let anyDropdownOpen = $derived(headingDropdown || colorDropdown || highlightDropdown || alignDropdown || insertDropdown || tablePickerOpen); let editorState = $state(0); + let editorStateRaf = 0; // RAF handle for batching toolbar updates // AI let aiMenu = $state<{ x: number; y: number } | null>(null); @@ -169,6 +170,8 @@ .use(markdownItMark) .use(markdownItSup) .use(markdownItSub); + // Disable indented code blocks — tab-indented text should stay as text, not become code + mdit.disable('code'); function normalizePath(p: string): string { const parts = p.split('/'); @@ -313,37 +316,60 @@ const CodeBlockLanguageSelect = Extension.create({ name: 'codeBlockLanguageSelect', + addGlobalAttributes() { + return [{ + types: ['codeBlock'], + attributes: { + language: { + renderHTML: (attributes) => { + return { 'data-language': attributes.language || '' }; + }, + }, + }, + }]; + }, addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('codeBlockLanguageSelect'), props: { - decorations: (state) => { - const decorations: Decoration[] = []; - state.doc.descendants((node, pos) => { - if (node.type.name === 'codeBlock') { - const wrapper = document.createElement('div'); - wrapper.className = 'code-lang-wrapper'; - - const btn = document.createElement('button'); - btn.className = 'code-lang-btn'; - if (node.attrs.language) { - btn.textContent = node.attrs.language; - } else { - btn.innerHTML = ''; + handleDOMEvents: { + click: (view, event) => { + const target = event.target as HTMLElement; + const pre = target.closest('pre'); + if (!pre) return false; + // Check if click is in the top-right corner (language button area) + const rect = pre.getBoundingClientRect(); + if (event.clientX < rect.right - 70 || event.clientY > rect.top + 30) return false; + // Find the code block position + const pos = view.posAtDOM(pre, 0); + const resolved = view.state.doc.resolve(pos); + let cbNode = resolved.parent; + let cbPos = resolved.before(resolved.depth); + if (cbNode.type.name !== 'codeBlock') { + for (let d = resolved.depth; d >= 0; d--) { + if (resolved.node(d).type.name === 'codeBlock') { + cbNode = resolved.node(d); + cbPos = resolved.before(d); + break; + } } - btn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - openCodeLangDropdown(pos + 1, node.attrs.language || '', btn); - }); - btn.addEventListener('mousedown', (e) => e.stopPropagation()); - - wrapper.appendChild(btn); - decorations.push(Decoration.widget(pos + 1, wrapper, { side: -1, ignoreSelection: true })); } - }); - return DecorationSet.create(state.doc, decorations); + if (cbNode.type.name !== 'codeBlock') return false; + event.preventDefault(); + event.stopPropagation(); + // Virtual trigger for dropdown positioning + const triggerEl = document.createElement('div'); + triggerEl.getBoundingClientRect = () => ({ + top: rect.top + 6, bottom: rect.top + 26, + left: rect.right - 60, right: rect.right - 6, + width: 54, height: 20, + x: rect.right - 60, y: rect.top + 6, + toJSON() { return this; }, + }); + openCodeLangDropdown(cbPos + 1, cbNode.attrs.language || '', triggerEl as any); + return true; + }, }, }, }), @@ -1077,9 +1103,9 @@ let preserveEmptyParas = false; doc.forEach((node: any) => { const isEmpty = node.type.name === 'paragraph' && node.childCount === 0; - // After a list or code block, preserve empty paragraphs as HTML comments - // so markdown-it doesn't merge adjacent lists or collapse spacing - if (listTypes.has(prevType) || prevType === 'codeBlock') { + // After a list, code block, or blockquote, preserve empty paragraphs as HTML comments + // so markdown-it doesn't merge adjacent blocks or collapse spacing + if (listTypes.has(prevType) || prevType === 'codeBlock' || prevType === 'blockquote') { preserveEmptyParas = true; } if (isEmpty && preserveEmptyParas) { @@ -1183,6 +1209,12 @@ node.forEach((child: any) => { if (child.type.name === 'paragraph') { parts.push(serializeInline(child)); + } else if (child.type.name === 'bulletList' || child.type.name === 'orderedList' || child.type.name === 'taskList') { + // Indent nested lists so markdown parsers recognize nesting + // Use 4 spaces — works for both bullet (- ) and ordered (1. ) parent markers + const nested = serializeNode(child).replace(/\n$/, ''); + const indented = nested.split('\n').map((line: string) => ' ' + line).join('\n'); + parts.push(indented); } else { parts.push(serializeNode(child)); } @@ -1193,9 +1225,15 @@ function serializeInline(node: any): string { if (node.childCount === 0) return ''; const parts: string[] = []; - node.forEach((child: any) => { + node.forEach((child: any, _offset: number, index: number) => { if (child.isText) { let text = child.text || ''; + // Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip + // (markdown parsers strip tab whitespace, but   passes through as HTML) + // Tabs come from initial indent; em-spaces (U+2003) come from prior   roundtrips + if (index === 0) { + text = text.replace(/^[\t\u2003]+/, (ws) => ' '.repeat(ws.length)); + } // Apply marks for (const mark of child.marks) { switch (mark.type.name) { @@ -1215,6 +1253,11 @@ } break; } + case 'textStyle': { + const c = mark.attrs?.color; + if (c) text = `${text}`; + break; + } case 'link': text = `[${text}](${mark.attrs.href})`; break; case 'wikiLink': text = `[[${mark.attrs.title || text}]]`; break; } @@ -1485,10 +1528,11 @@ }); // Pre-process: convert task list syntax before markdown-it (it doesn't know TipTap's format) - src = src.replace(/^- \[x\][^\S\n]+(.+)$/gm, '- $1'); - src = src.replace(/^- \[x\][^\S\n]*$/gm, '-  '); - src = src.replace(/^- \[ \][^\S\n]+(.+)$/gm, '- $1'); - src = src.replace(/^- \[ \][^\S\n]*$/gm, '-  '); + // Support indented (nested) and blockquoted task lists too + src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]+(.+)$/gm, '$1- $2'); + src = src.replace(/^([\s>]*)-\s\[x\][^\S\n]*$/gm, '$1-  '); + src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]+(.+)$/gm, '$1- $2'); + src = src.replace(/^([\s>]*)-\s\[ \][^\S\n]*$/gm, '$1-  '); // Run markdown-it (single-pass parser — handles headings, bold, italic, strike, code, blockquote, lists, links, images, hr, tables, raw HTML) let html = mdit.render(src); @@ -1500,11 +1544,12 @@ // Post-process: convert list-separator comments back to empty paragraphs for TipTap html = html.replace(//g, '

'); - // Post-process: convert task list items to TipTap format (handle both tight and loose lists — loose lists wrap content in

tags) - html = html.replace(/

  • \s*(?:

    )?\s*([\s\S]*?)<\/tiptask>\s*(?:<\/p>)?\s*<\/li>/gi, (_, checked, content) => { - return `

  • ${content}
  • `; + // Post-process: convert task list items to TipTap format + // Convert opening
  • + into data-attributed
  • , handles both tight and loose (with

    ) lists + html = html.replace(/

  • (\s*(?:

    )?)\s*([\s\S]*?)<\/tiptask>\s*(?:<\/p>)?/gi, (_, _pre, checked, text) => { + return `

  • ${text}`; }); - html = html.replace(/
      \s*(
    • $1'); + html = html.replace(/
        (\s*
      • $1'); // Post-process: resolve image src paths and parse size attribute html = html.replace(/]*\/?>/gi, (_, imgSrc, altRaw) => { @@ -1546,6 +1591,19 @@ return () => document.removeEventListener('mousedown', onClickAway); }); + // Close in-note search when switching notes + let prevSearchPath = ''; + $effect(() => { + const path = $activeNotePath ?? ''; + if (prevSearchPath && path !== prevSearchPath) { + noteSearchOpen = false; + noteSearchQuery = ''; + noteSearchResults = []; + noteSearchIndex = 0; + } + prevSearchPath = path; + }); + // React to activeNotePath changes from external sources (e.g. search panel) $effect(() => { const path = $activeNotePath; @@ -1617,11 +1675,31 @@ content: html, editorProps: { attributes: { class: 'editor-content' }, + handleDOMEvents: { + // Prevent details toggle button from stealing focus, which causes scroll-to-top. + // Also pre-focus the editor with preventScroll so TipTap's focus command + // sees hasFocus()=true and skips its scrolling view.focus() call. + mousedown: (view, event) => { + const target = event.target as HTMLElement; + if (target.closest('[data-type="details"] > button')) { + event.preventDefault(); + if (!view.hasFocus()) { + (view.dom as HTMLElement).focus({ preventScroll: true }); + } + } + }, + }, handleDrop: (_view, event) => handleFileDrop(event), handlePaste: (_view, event) => handleFilePaste(event), }, onTransaction: () => { - editorState++; + // Batch toolbar state updates to once per frame — avoids ~35 isActive() calls per transaction during selection drag + if (!editorStateRaf) { + editorStateRaf = requestAnimationFrame(() => { + editorStateRaf = 0; + editorState++; + }); + } updateSlashMenu(); updateWikiLinkMenu(); }, @@ -4123,48 +4201,43 @@ border-radius: 8px; padding: 16px; margin: 1em 0; - overflow-x: auto; position: relative; } :global(.tiptap-wrapper .tiptap pre code) { + display: block; + overflow-x: auto; background: none; padding: 0; font-size: 13px; line-height: 1.5; } - :global(.tiptap-wrapper .tiptap pre .code-lang-wrapper) { + :global(.tiptap-wrapper .tiptap pre)::after { + content: attr(data-language); position: absolute; top: 6px; right: 6px; - z-index: 1; - } - - :global(.tiptap-wrapper .tiptap pre .code-lang-btn) { padding: 2px 6px; - border: none; border-radius: 4px; background: transparent; color: var(--text-tertiary); font-size: 11px; font-family: 'JetBrains Mono', 'Fira Code', monospace; cursor: pointer; - outline: none; - opacity: 0.4; - transition: opacity 0.15s, background 0.15s, color 0.15s; + opacity: 0; + pointer-events: none; + z-index: 1; } - :global(.tiptap-wrapper .tiptap pre:hover .code-lang-btn) { + :global(.tiptap-wrapper .tiptap pre[data-language=""])::after { + content: '•••'; + } + + :global(.tiptap-wrapper .tiptap pre:hover)::after { opacity: 0.7; } - :global(.tiptap-wrapper .tiptap pre .code-lang-btn:hover) { - opacity: 1; - background: color-mix(in srgb, var(--text-primary) 10%, transparent); - color: var(--text-secondary); - } - .code-lang-overlay { position: fixed; inset: 0; diff --git a/src/lib/components/SettingsPanel.svelte b/src/lib/components/SettingsPanel.svelte index 7ee3019..8442550 100644 --- a/src/lib/components/SettingsPanel.svelte +++ b/src/lib/components/SettingsPanel.svelte @@ -1,5 +1,5 @@