diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index c7b72cd..c3b4331 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -269,6 +269,8 @@ pub struct VaultState { #[serde(default)] pub notebook_order: std::collections::HashMap, #[serde(default)] + pub note_order: std::collections::HashMap, + #[serde(default)] pub sort_mode: String, #[serde(default)] pub last_view_mode: String, @@ -297,6 +299,7 @@ impl Default for VaultState { collapsed_notebooks: Vec::new(), notebook_sort_mode: String::new(), notebook_order: std::collections::HashMap::new(), + note_order: std::collections::HashMap::new(), sort_mode: String::new(), last_view_mode: String::new(), last_notebook: None, diff --git a/src/lib/components/AppLayout.svelte b/src/lib/components/AppLayout.svelte index f4f51bc..48e4433 100644 --- a/src/lib/components/AppLayout.svelte +++ b/src/lib/components/AppLayout.svelte @@ -49,6 +49,7 @@ viewerNote, notebookSortMode, notebookOrder, + noteOrder, syncState, platformIsMobile } from '$lib/stores/app'; @@ -206,6 +207,7 @@ collapsed_notebooks: $collapsedNotebooks, notebook_sort_mode: $notebookSortMode, notebook_order: $notebookOrder, + note_order: $noteOrder, sort_mode: $sortMode, last_view_mode: $viewMode, last_notebook: $activeNotebook?.relative_path ?? null, @@ -528,6 +530,11 @@ persistState(); }); + $effect(() => { + $noteOrder; + persistState(); + }); + $effect(() => { $sortMode; $tasksLayout; @@ -558,7 +565,8 @@ $collapsedNotebooks = state.collapsed_notebooks ?? []; $notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical'; $notebookOrder = state.notebook_order ?? {}; - if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode; + $noteOrder = state.note_order ?? {}; + if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode; if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout; if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed; if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged; diff --git a/src/lib/components/NoteList.svelte b/src/lib/components/NoteList.svelte index 23b8570..22385d4 100644 --- a/src/lib/components/NoteList.svelte +++ b/src/lib/components/NoteList.svelte @@ -13,6 +13,7 @@ quickAccessPaths, appConfig, notelistCollapsed, + noteOrder, notebooks, tags, mobileView @@ -206,6 +207,11 @@ let qaDragFrom = $state(null); let qaDragOver = $state(null); let qaDragHalf = $state<'top' | 'bottom'>('bottom'); + let noteDragFrom = $state(null); + let noteDragOver = $state(null); + let noteDragHalf = $state<'top' | 'bottom'>('bottom'); + const customOrderViews = new Set(['all', 'notebook', 'tag']); + let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode)); // Virtual scroll let listContainer = $state(null!); @@ -415,6 +421,7 @@ } try { const entry = await createNote(nbRelative, 'Untitled'); + if ($sortMode === 'custom') appendManualNoteOrder(entry.path); noteCache.clear(); await refresh(); await selectNote(entry); @@ -432,6 +439,7 @@ try { const newTitle = editValue.trim(); const newPath = await renameNote(note.path, newTitle); + renameManualNoteOrderPath(note.path, newPath); noteCache.clear(); editingNote = null; // Update local store immediately (avoid full vault re-scan) @@ -453,6 +461,7 @@ contextMenu = null; try { await deleteNote(note.path); + removeManualNoteOrderPath(note.path); noteCache.clear(); // Remove from local store immediately (avoid full vault re-scan) $notes = $notes.filter(n => n.path !== note.path); @@ -480,6 +489,7 @@ contextMenu = null; try { await permanentDelete(note.path); + removeManualNoteOrderPath(note.path); noteCache.clear(); $notes = $notes.filter(n => n.path !== note.path); if ($activeNotePath === note.path) { @@ -673,11 +683,60 @@ } } + function persistManualNoteOrder(orderedNotes: NoteEntry[]) { + // Merge into (not replace) the order map so reordering notes in one view does + // not wipe the saved custom order of other notebooks / tags / the all-notes view. + const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index])); + $noteOrder = { ...$noteOrder, ...updates }; + } + + function appendManualNoteOrder(path: string) { + const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1; + $noteOrder = { ...$noteOrder, [path]: nextIndex }; + } + + function renameManualNoteOrderPath(oldPath: string, newPath: string) { + if (!Object.hasOwn($noteOrder, oldPath)) return; + const { [oldPath]: index, ...rest } = $noteOrder; + $noteOrder = { ...rest, [newPath]: index }; + } + + function removeManualNoteOrderPath(path: string) { + if (!Object.hasOwn($noteOrder, path)) return; + const { [path]: _, ...rest } = $noteOrder; + $noteOrder = rest; + } + + function resetNoteDrag() { + noteDragFrom = null; + noteDragOver = null; + } + + function handleCustomNoteDrop(targetIndex: number) { + if (noteDragFrom === null || !canCustomReorder) { + resetNoteDrag(); + return; + } + let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex; + if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) { + resetNoteDrag(); + return; + } + const arr = [...$sortedNotes]; + const [moved] = arr.splice(noteDragFrom, 1); + if (insertAt > noteDragFrom) insertAt--; + arr.splice(insertAt, 0, moved); + $notes = arr; + persistManualNoteOrder(arr); + resetNoteDrag(); + } + async function handleMoveNote(note: NoteEntry, destPath: string) { contextMenu = null; movePickerNote = null; try { const newPath = await moveNote(note.path, destPath); + renameManualNoteOrderPath(note.path, newPath); noteCache.clear(); $notes = $notes.filter(n => n.path !== note.path); if ($activeNotePath === note.path) { @@ -779,6 +838,7 @@ async function handleBatchDelete() { const toDelete = new Set(selectedPaths); noteCache.clear(); + for (const path of toDelete) removeManualNoteOrderPath(path); $notes = $notes.filter(n => !toDelete.has(n.path)); if ($activeNotePath && toDelete.has($activeNotePath)) { $activeNote = null; @@ -814,6 +874,7 @@ async function handleBatchPermanentDelete() { const toDelete = new Set(selectedPaths); noteCache.clear(); + for (const path of toDelete) removeManualNoteOrderPath(path); $notes = $notes.filter(n => !toDelete.has(n.path)); if ($activeNotePath && toDelete.has($activeNotePath)) { $activeNote = null; @@ -839,6 +900,9 @@ } function setSortMode(mode: SortMode) { + if (mode === 'custom' && Object.keys($noteOrder).length === 0) { + persistManualNoteOrder($sortedNotes); + } $sortMode = mode; sortMenu = null; } @@ -1089,6 +1153,8 @@ 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'} + class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'} + class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'} onclick={(e) => handleNoteClick(e, note)} onkeydown={(e) => { if (e.key === 'F2') { @@ -1122,6 +1188,12 @@ e.dataTransfer!.effectAllowed = 'move'; return; } + if (canCustomReorder) { + noteDragFrom = 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 { @@ -1136,18 +1208,28 @@ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom'; qaDragOver = noteIndex; + } else if (canCustomReorder && noteDragFrom !== null) { + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom'; + noteDragOver = noteIndex; } }} ondragleave={() => { if (qaDragOver === noteIndex) qaDragOver = null; + if (noteDragOver === noteIndex) noteDragOver = null; }} ondrop={(e) => { if ($viewMode === 'quickaccess' && qaDragFrom !== null) { e.preventDefault(); handleQaDrop(noteIndex); + } else if (canCustomReorder && noteDragFrom !== null) { + e.preventDefault(); + handleCustomNoteDrop(noteIndex); } }} - ondragend={() => { qaDragFrom = null; qaDragOver = null; }} + ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }} > {#if compact}
@@ -1426,6 +1508,18 @@ Title {#if $sortMode === 'title'}{/if} +
{/if} @@ -1948,11 +2042,13 @@ font-size: 14px; } - .note-item.qa-drag-above { + .note-item.qa-drag-above, + .note-item.note-drag-above { border-top: 2px solid var(--accent); } - .note-item.qa-drag-below { + .note-item.qa-drag-below, + .note-item.note-drag-below { border-bottom: 2px solid var(--accent); } diff --git a/src/lib/stores/app.ts b/src/lib/stores/app.ts index 4e44090..13ae8da 100644 --- a/src/lib/stores/app.ts +++ b/src/lib/stores/app.ts @@ -40,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">( ); // Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'. export const notebookOrder = writable>({}); +export const noteOrder = writable>({}); // Data export const notebooks = writable([]); @@ -177,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length // Derived export const sortedNotes = derived( - [notes, sortMode, viewMode], - ([$notes, $sortMode, $viewMode]) => { + [notes, sortMode, viewMode, noteOrder], + ([$notes, $sortMode, $viewMode, $noteOrder]) => { // Quick Access preserves stored order if ($viewMode === "quickaccess") return $notes; + const customSortable = + $viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag"; const pinned = $notes.filter((n) => n.meta.pinned); const unpinned = $notes.filter((n) => !n.meta.pinned); const sortFn = (a: NoteEntry, b: NoteEntry) => { switch ($sortMode) { + case "custom": { + if (!customSortable) { + return ( + new Date(b.meta.modified).getTime() - + new Date(a.meta.modified).getTime() + ); + } + const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER; + const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER; + if (oa !== ob) return oa - ob; + return a.meta.title.localeCompare(b.meta.title); + } case "title": return a.meta.title.localeCompare(b.meta.title); case "created": @@ -217,6 +232,7 @@ export const vaultState = derived( collapsedNotebooks, notebookSortMode, notebookOrder, + noteOrder, ], ([ $activeNotePath, @@ -227,6 +243,7 @@ export const vaultState = derived( $collapsedNotebooks, $notebookSortMode, $notebookOrder, + $noteOrder, ]) => { return { last_open_note: $activeNotePath, @@ -237,6 +254,7 @@ export const vaultState = derived( collapsed_notebooks: $collapsedNotebooks, notebook_sort_mode: $notebookSortMode, notebook_order: $notebookOrder, + note_order: $noteOrder, } satisfies VaultState; }, ); diff --git a/src/lib/types.ts b/src/lib/types.ts index a2cd37d..f491bf8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -109,6 +109,7 @@ export interface VaultState { collapsed_notebooks: string[]; notebook_sort_mode?: string; notebook_order?: Record; + note_order?: Record; sort_mode?: string; last_view_mode?: string; last_notebook?: string | null; @@ -167,7 +168,7 @@ export interface NoteTitleEntry { path: string; } -export type SortMode = "modified" | "title" | "created"; +export type SortMode = "modified" | "title" | "created" | "custom"; export type ViewMode = | "all" | "notebook"