v1.0.4 - Quick Access reorder, performance improvements, search fixes

- Quick Access drag-to-reorder: drag notes to rearrange, order persists
- Quick Access preserves order when moving notes between notebooks
- In-note search: debounced input, proper scroll-to-match
- Panel resize: RAF-batched updates, editor containment during resize
- Thinner resize handles (3px)
- Note list: removed transition, added CSS containment for 1200+ notes
- Escape closes Settings and Info panels
- Fix Quick Access cache invalidation on remove
This commit is contained in:
Yuri Karamian
2026-02-10 21:14:50 +01:00
parent a95e240bcd
commit f688cf4bd4
12 changed files with 193 additions and 51 deletions
+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.3", "version": "1.0.4",
"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.3" version = "1.0.4"
dependencies = [ dependencies = [
"chrono", "chrono",
"dirs", "dirs",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.0.3" version = "1.0.4"
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"
+41 -2
View File
@@ -2,6 +2,7 @@ use crate::search::SearchIndex;
use crate::state::AppState; use crate::state::AppState;
use crate::types::*; use crate::types::*;
use crate::vault::{operations, watcher}; use crate::vault::{operations, watcher};
use std::path::Path;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
// ── Vault Management ── // ── Vault Management ──
@@ -195,8 +196,39 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
} }
#[tauri::command] #[tauri::command]
pub fn move_note(note_path: String, dest_notebook: String) -> Result<String, String> { pub fn move_note(
operations::move_note(&note_path, &dest_notebook) state: State<'_, AppState>,
note_path: String,
dest_notebook: String,
) -> Result<String, 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")?;
// Compute old relative path before move
let old_relative = Path::new(&note_path)
.strip_prefix(vault_path)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let new_full_path = operations::move_note(&note_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 ── // ── Tags ──
@@ -437,6 +469,13 @@ pub fn remove_quick_access(
operations::remove_quick_access(vault_path, &note_relative) operations::remove_quick_access(vault_path, &note_relative)
} }
#[tauri::command]
pub fn reorder_quick_access(state: State<'_, AppState>, paths: Vec<String>) -> 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] #[tauri::command]
pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> { pub fn get_vault_stats(state: State<'_, AppState>) -> Result<VaultStats, String> {
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
+1
View File
@@ -79,6 +79,7 @@ pub fn run() {
commands::get_quick_access, commands::get_quick_access,
commands::add_quick_access, commands::add_quick_access,
commands::remove_quick_access, commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats, commands::get_vault_stats,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
+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.3", "version": "1.0.4",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+10 -1
View File
@@ -31,7 +31,7 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--sidebar-width: 220px; --sidebar-width: 220px;
--notelist-width: 280px; --notelist-width: 280px;
--panel-resize-handle: 4px; --panel-resize-handle: 3px;
} }
:root.dark { :root.dark {
@@ -149,3 +149,12 @@ body {
.resize-handle.active { .resize-handle.active {
background: var(--accent); background: var(--accent);
} }
body.resizing .ProseMirror {
pointer-events: none;
contain: strict;
}
body.resizing {
user-select: none;
}
+4
View File
@@ -213,6 +213,10 @@ export async function removeQuickAccess(noteRelative: string): Promise<void> {
return invoke("remove_quick_access", { noteRelative }); return invoke("remove_quick_access", { noteRelative });
} }
export async function reorderQuickAccess(paths: string[]): Promise<void> {
return invoke("reorder_quick_access", { paths });
}
export async function getVaultStats(): Promise<VaultStats> { export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats"); return invoke("get_vault_stats");
} }
+26 -17
View File
@@ -1192,7 +1192,10 @@
} }
// ── In-note search functions ── // ── In-note search functions ──
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
function updateNoteSearch(query: string) { function updateNoteSearch(query: string) {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
if (!editor) return; if (!editor) return;
if (!query) { if (!query) {
noteSearchResults = []; noteSearchResults = [];
@@ -1201,20 +1204,23 @@
editor.view.dispatch(tr); editor.view.dispatch(tr);
return; return;
} }
const results: {from: number, to: number}[] = []; noteSearchTimer = setTimeout(() => {
const lowerQuery = query.toLowerCase(); if (!editor) return;
editor.state.doc.descendants((node, pos) => { const results: {from: number, to: number}[] = [];
if (!node.isText || !node.text) return; const lowerQuery = query.toLowerCase();
const text = node.text.toLowerCase(); editor.state.doc.descendants((node, pos) => {
let idx = text.indexOf(lowerQuery); if (!node.isText || !node.text) return;
while (idx !== -1) { const text = node.text.toLowerCase();
results.push({ from: pos + idx, to: pos + idx + query.length }); let idx = text.indexOf(lowerQuery);
idx = text.indexOf(lowerQuery, idx + 1); 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(); noteSearchResults = results;
if (noteSearchIndex >= results.length) noteSearchIndex = 0;
applySearchDecorations();
}, 100);
} }
function applySearchDecorations() { function applySearchDecorations() {
@@ -1230,9 +1236,12 @@
function scrollToCurrentMatch() { function scrollToCurrentMatch() {
if (!editor || noteSearchResults.length === 0) return; if (!editor || noteSearchResults.length === 0) return;
const match = noteSearchResults[noteSearchIndex]; requestAnimationFrame(() => {
editor.commands.setTextSelection(match.from); const el = editor?.view.dom.querySelector('.note-search-active');
editor.commands.scrollIntoView(); if (el) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
} }
function noteSearchNext() { function noteSearchNext() {
+65 -3
View File
@@ -26,6 +26,7 @@
getQuickAccess, getQuickAccess,
addQuickAccess, addQuickAccess,
removeQuickAccess, removeQuickAccess,
reorderQuickAccess,
moveNote moveNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime } from '$lib/utils/time';
@@ -48,6 +49,11 @@
let lastClickedPath = $state<string | null>(null); let lastClickedPath = $state<string | null>(null);
let batchMovePicker = $state(false); let batchMovePicker = $state(false);
// Quick Access drag-to-reorder
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
function clearSelection() { function clearSelection() {
selectedPaths = new Set(); selectedPaths = new Set();
lastClickedPath = null; lastClickedPath = null;
@@ -260,12 +266,33 @@
await removeQuickAccess(note.relative_path); await removeQuickAccess(note.relative_path);
const qaNotes = await getQuickAccess(); const qaNotes = await getQuickAccess();
$quickAccessPaths = qaNotes.map(n => n.relative_path); $quickAccessPaths = qaNotes.map(n => n.relative_path);
if ($viewMode === 'quickaccess') await refresh(); if ($viewMode === 'quickaccess') await refresh(true);
} catch (e) { } catch (e) {
console.error('Failed to remove from quick access:', 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) { async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null; contextMenu = null;
movePickerNote = null; movePickerNote = null;
@@ -491,7 +518,7 @@
</div> </div>
{/if} {/if}
{#each $sortedNotes as note (note.path)} {#each $sortedNotes as note, noteIndex (note.path)}
{#if editingNote === note.path} {#if editingNote === note.path}
<div class="note-item active"> <div class="note-item active">
<input <input
@@ -512,6 +539,8 @@
class:selected={selectedPaths.has(note.path)} class:selected={selectedPaths.has(note.path)}
class:pinned={note.meta.pinned} class:pinned={note.meta.pinned}
class:compact={compact} class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
onclick={(e) => handleNoteClick(e, note)} onclick={(e) => handleNoteClick(e, note)}
oncontextmenu={(e) => { oncontextmenu={(e) => {
e.preventDefault(); e.preventDefault();
@@ -528,6 +557,12 @@
}} }}
draggable="true" draggable="true"
ondragstart={(e) => { 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)) { if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n')); e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
} else { } else {
@@ -535,6 +570,25 @@
} }
e.dataTransfer!.effectAllowed = 'move'; 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} {#if compact}
<div class="note-compact-row"> <div class="note-compact-row">
@@ -761,8 +815,8 @@
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s;
margin-bottom: 1px; margin-bottom: 1px;
contain: content;
} }
.note-item:hover { .note-item:hover {
@@ -1133,4 +1187,12 @@
color: var(--accent); color: var(--accent);
font-size: 14px; 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);
}
</style> </style>
+14 -2
View File
@@ -2,23 +2,35 @@
let { onResize }: { onResize: (delta: number) => void } = $props(); let { onResize }: { onResize: (delta: number) => void } = $props();
let active = $state(false); let active = $state(false);
let startX = 0; let startX = 0;
let rafId = 0;
let pendingDelta = 0;
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
active = true; active = true;
startX = e.clientX; startX = e.clientX;
document.body.classList.add('resizing');
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp); window.addEventListener('mouseup', onMouseUp);
} }
function onMouseMove(e: MouseEvent) { function onMouseMove(e: MouseEvent) {
const delta = e.clientX - startX; pendingDelta += e.clientX - startX;
startX = e.clientX; startX = e.clientX;
onResize(delta); if (!rafId) {
rafId = requestAnimationFrame(() => {
onResize(pendingDelta);
pendingDelta = 0;
rafId = 0;
});
}
} }
function onMouseUp() { function onMouseUp() {
active = false; 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('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mouseup', onMouseUp);
} }
+28 -22
View File
@@ -66,30 +66,36 @@ export async function checkForUpdate() {
} }
// Derived // Derived
export const sortedNotes = derived([notes, sortMode], ([$notes, $sortMode]) => { export const sortedNotes = derived(
const pinned = $notes.filter((n) => n.meta.pinned); [notes, sortMode, viewMode],
const unpinned = $notes.filter((n) => !n.meta.pinned); ([$notes, $sortMode, $viewMode]) => {
// Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes;
const sortFn = (a: NoteEntry, b: NoteEntry) => { const pinned = $notes.filter((n) => n.meta.pinned);
switch ($sortMode) { const unpinned = $notes.filter((n) => !n.meta.pinned);
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)]; 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( export const vaultState = derived(
[ [