diff --git a/package.json b/package.json index 541878f..0d9a7fe 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "helixnotes", "private": true, "license": "AGPL-3.0-or-later", - "version": "1.0.3", + "version": "1.0.4", "type": "module", "scripts": { "dev": "vite dev", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 96647bf..9156e63 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1778,7 +1778,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "helixnotes" -version = "1.0.3" +version = "1.0.4" dependencies = [ "chrono", "dirs", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9df50de..f35ab67 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "helixnotes" -version = "1.0.3" +version = "1.0.4" 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 0b00e29..d017435 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2,6 +2,7 @@ use crate::search::SearchIndex; use crate::state::AppState; use crate::types::*; use crate::vault::{operations, watcher}; +use std::path::Path; use tauri::{AppHandle, Manager, State}; // ── Vault Management ── @@ -195,8 +196,39 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin } #[tauri::command] -pub fn move_note(note_path: String, dest_notebook: String) -> Result { - operations::move_note(¬e_path, &dest_notebook) +pub fn move_note( + state: State<'_, AppState>, + note_path: String, + dest_notebook: 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")?; + + // Compute old relative path before move + let old_relative = Path::new(¬e_path) + .strip_prefix(vault_path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let new_full_path = operations::move_note(¬e_path, &dest_notebook)?; + + // Update quick access if the moved note was in it + if !old_relative.is_empty() { + if let Ok(mut qa) = operations::load_quick_access(vault_path) { + if let Some(pos) = qa.iter().position(|p| *p == old_relative) { + let new_relative = Path::new(&new_full_path) + .strip_prefix(vault_path) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + if !new_relative.is_empty() { + qa[pos] = new_relative; + let _ = operations::save_quick_access(vault_path, &qa); + } + } + } + } + + Ok(new_full_path) } // ── Tags ── @@ -437,6 +469,13 @@ pub fn remove_quick_access( operations::remove_quick_access(vault_path, ¬e_relative) } +#[tauri::command] +pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec) -> Result<(), String> { + let config = state.config.lock().map_err(|e| e.to_string())?; + let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; + operations::save_quick_access(vault_path, &paths) +} + #[tauri::command] pub fn get_vault_stats(state: State<'_, AppState>) -> Result { let config = state.config.lock().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09eeaa3..8e94cfd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -79,6 +79,7 @@ pub fn run() { commands::get_quick_access, commands::add_quick_access, commands::remove_quick_access, + commands::reorder_quick_access, commands::get_vault_stats, commands::import_obsidian, commands::open_file, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 4f59327..d2aa830 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.3", + "version": "1.0.4", "identifier": "com.helixnotes.app", "build": { "frontendDist": "../build", diff --git a/src/app.css b/src/app.css index 5a60f85..0f0eb03 100644 --- a/src/app.css +++ b/src/app.css @@ -31,7 +31,7 @@ --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); --sidebar-width: 220px; --notelist-width: 280px; - --panel-resize-handle: 4px; + --panel-resize-handle: 3px; } :root.dark { @@ -149,3 +149,12 @@ body { .resize-handle.active { background: var(--accent); } + +body.resizing .ProseMirror { + pointer-events: none; + contain: strict; +} + +body.resizing { + user-select: none; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 13c13d8..feb0ab8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -213,6 +213,10 @@ export async function removeQuickAccess(noteRelative: string): Promise { return invoke("remove_quick_access", { noteRelative }); } +export async function reorderQuickAccess(paths: string[]): Promise { + return invoke("reorder_quick_access", { paths }); +} + export async function getVaultStats(): Promise { return invoke("get_vault_stats"); } diff --git a/src/lib/components/Editor.svelte b/src/lib/components/Editor.svelte index 44b1676..1e453c5 100644 --- a/src/lib/components/Editor.svelte +++ b/src/lib/components/Editor.svelte @@ -1192,7 +1192,10 @@ } // ── In-note search functions ── + let noteSearchTimer: ReturnType | null = null; + function updateNoteSearch(query: string) { + if (noteSearchTimer) clearTimeout(noteSearchTimer); if (!editor) return; if (!query) { noteSearchResults = []; @@ -1201,20 +1204,23 @@ editor.view.dispatch(tr); return; } - const results: {from: number, to: number}[] = []; - const lowerQuery = query.toLowerCase(); - editor.state.doc.descendants((node, pos) => { - if (!node.isText || !node.text) return; - const text = node.text.toLowerCase(); - let idx = text.indexOf(lowerQuery); - while (idx !== -1) { - results.push({ from: pos + idx, to: pos + idx + query.length }); - idx = text.indexOf(lowerQuery, idx + 1); - } - }); - noteSearchResults = results; - if (noteSearchIndex >= results.length) noteSearchIndex = 0; - applySearchDecorations(); + noteSearchTimer = setTimeout(() => { + if (!editor) return; + const results: {from: number, to: number}[] = []; + const lowerQuery = query.toLowerCase(); + editor.state.doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + const text = node.text.toLowerCase(); + let idx = text.indexOf(lowerQuery); + while (idx !== -1) { + results.push({ from: pos + idx, to: pos + idx + query.length }); + idx = text.indexOf(lowerQuery, idx + 1); + } + }); + noteSearchResults = results; + if (noteSearchIndex >= results.length) noteSearchIndex = 0; + applySearchDecorations(); + }, 100); } function applySearchDecorations() { @@ -1230,9 +1236,12 @@ function scrollToCurrentMatch() { if (!editor || noteSearchResults.length === 0) return; - const match = noteSearchResults[noteSearchIndex]; - editor.commands.setTextSelection(match.from); - editor.commands.scrollIntoView(); + requestAnimationFrame(() => { + const el = editor?.view.dom.querySelector('.note-search-active'); + if (el) { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }); } function noteSearchNext() { diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 72f28a9..8ca79b1 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -26,6 +26,7 @@ getQuickAccess, addQuickAccess, removeQuickAccess, + reorderQuickAccess, moveNote } from '$lib/api'; import { formatRelativeTime } from '$lib/utils/time'; @@ -48,6 +49,11 @@ let lastClickedPath = $state(null); let batchMovePicker = $state(false); + // Quick Access drag-to-reorder + let qaDragFrom = $state(null); + let qaDragOver = $state(null); + let qaDragHalf = $state<'top' | 'bottom'>('bottom'); + function clearSelection() { selectedPaths = new Set(); lastClickedPath = null; @@ -260,12 +266,33 @@ await removeQuickAccess(note.relative_path); const qaNotes = await getQuickAccess(); $quickAccessPaths = qaNotes.map(n => n.relative_path); - if ($viewMode === 'quickaccess') await refresh(); + if ($viewMode === 'quickaccess') await refresh(true); } catch (e) { console.error('Failed to remove from quick access:', e); } } + async function handleQaDrop(targetIndex: number) { + if (qaDragFrom === null) { qaDragFrom = null; qaDragOver = null; return; } + // Compute the actual insert position based on which half we're hovering + let insertAt = qaDragHalf === 'bottom' ? targetIndex + 1 : targetIndex; + if (qaDragFrom === insertAt || qaDragFrom + 1 === insertAt) { + qaDragFrom = null; qaDragOver = null; return; + } + const arr = [...$sortedNotes]; + const [moved] = arr.splice(qaDragFrom, 1); + if (insertAt > qaDragFrom) insertAt--; + arr.splice(insertAt, 0, moved); + $notes = arr; + qaDragFrom = null; + qaDragOver = null; + try { + await reorderQuickAccess(arr.map(n => n.relative_path)); + } catch (e) { + console.error('Failed to reorder quick access:', e); + } + } + async function handleMoveNote(note: NoteEntry, destPath: string) { contextMenu = null; movePickerNote = null; @@ -491,7 +518,7 @@ {/if} - {#each $sortedNotes as note (note.path)} + {#each $sortedNotes as note, noteIndex (note.path)} {#if editingNote === note.path}
handleNoteClick(e, note)} oncontextmenu={(e) => { e.preventDefault(); @@ -528,6 +557,12 @@ }} draggable="true" ondragstart={(e) => { + if ($viewMode === 'quickaccess') { + qaDragFrom = noteIndex; + e.dataTransfer!.setData('text/plain', note.path); + e.dataTransfer!.effectAllowed = 'move'; + return; + } if (selectedPaths.size > 1 && selectedPaths.has(note.path)) { e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n')); } else { @@ -535,6 +570,25 @@ } e.dataTransfer!.effectAllowed = 'move'; }} + ondragover={(e) => { + if ($viewMode === 'quickaccess' && qaDragFrom !== null) { + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom'; + qaDragOver = noteIndex; + } + }} + ondragleave={() => { + if (qaDragOver === noteIndex) qaDragOver = null; + }} + ondrop={(e) => { + if ($viewMode === 'quickaccess' && qaDragFrom !== null) { + e.preventDefault(); + handleQaDrop(noteIndex); + } + }} + ondragend={() => { qaDragFrom = null; qaDragOver = null; }} > {#if compact}
@@ -761,8 +815,8 @@ border-radius: 6px; cursor: pointer; text-align: left; - transition: background 0.1s; margin-bottom: 1px; + contain: content; } .note-item:hover { @@ -1133,4 +1187,12 @@ color: var(--accent); font-size: 14px; } + + .note-item.qa-drag-above { + border-top: 2px solid var(--accent); + } + + .note-item.qa-drag-below { + border-bottom: 2px solid var(--accent); + } diff --git a/src/lib/components/ResizeHandle.svelte b/src/lib/components/ResizeHandle.svelte index 2526c5c..15e43ec 100644 --- a/src/lib/components/ResizeHandle.svelte +++ b/src/lib/components/ResizeHandle.svelte @@ -2,23 +2,35 @@ let { onResize }: { onResize: (delta: number) => void } = $props(); let active = $state(false); let startX = 0; + let rafId = 0; + let pendingDelta = 0; function onMouseDown(e: MouseEvent) { e.preventDefault(); active = true; startX = e.clientX; + document.body.classList.add('resizing'); window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); } function onMouseMove(e: MouseEvent) { - const delta = e.clientX - startX; + pendingDelta += e.clientX - startX; startX = e.clientX; - onResize(delta); + if (!rafId) { + rafId = requestAnimationFrame(() => { + onResize(pendingDelta); + pendingDelta = 0; + rafId = 0; + }); + } } function onMouseUp() { active = false; + document.body.classList.remove('resizing'); + if (rafId) { cancelAnimationFrame(rafId); rafId = 0; } + if (pendingDelta) { onResize(pendingDelta); pendingDelta = 0; } window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); } diff --git a/src/lib/stores/app.ts b/src/lib/stores/app.ts index 9d542aa..b0b9eb0 100644 --- a/src/lib/stores/app.ts +++ b/src/lib/stores/app.ts @@ -66,30 +66,36 @@ export async function checkForUpdate() { } // Derived -export const sortedNotes = derived([notes, sortMode], ([$notes, $sortMode]) => { - const pinned = $notes.filter((n) => n.meta.pinned); - const unpinned = $notes.filter((n) => !n.meta.pinned); +export const sortedNotes = derived( + [notes, sortMode, viewMode], + ([$notes, $sortMode, $viewMode]) => { + // Quick Access preserves stored order + if ($viewMode === "quickaccess") return $notes; - const sortFn = (a: NoteEntry, b: NoteEntry) => { - switch ($sortMode) { - case "title": - return a.meta.title.localeCompare(b.meta.title); - case "created": - return ( - new Date(b.meta.created).getTime() - - new Date(a.meta.created).getTime() - ); - case "modified": - default: - return ( - new Date(b.meta.modified).getTime() - - new Date(a.meta.modified).getTime() - ); - } - }; + const pinned = $notes.filter((n) => n.meta.pinned); + const unpinned = $notes.filter((n) => !n.meta.pinned); - return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]; -}); + const sortFn = (a: NoteEntry, b: NoteEntry) => { + switch ($sortMode) { + case "title": + return a.meta.title.localeCompare(b.meta.title); + case "created": + return ( + new Date(b.meta.created).getTime() - + new Date(a.meta.created).getTime() + ); + case "modified": + default: + return ( + new Date(b.meta.modified).getTime() - + new Date(a.meta.modified).getTime() + ); + } + }; + + return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]; + }, +); export const vaultState = derived( [