Add custom note sorting (#149)

This commit is contained in:
Yuri Karamian
2026-06-21 21:44:56 +02:00
5 changed files with 133 additions and 7 deletions
+3
View File
@@ -269,6 +269,8 @@ pub struct VaultState {
#[serde(default)] #[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>, pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)] #[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String, pub sort_mode: String,
#[serde(default)] #[serde(default)]
pub last_view_mode: String, pub last_view_mode: String,
@@ -297,6 +299,7 @@ impl Default for VaultState {
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(), notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(), notebook_order: std::collections::HashMap::new(),
note_order: std::collections::HashMap::new(),
sort_mode: String::new(), sort_mode: String::new(),
last_view_mode: String::new(), last_view_mode: String::new(),
last_notebook: None, last_notebook: None,
+9 -1
View File
@@ -49,6 +49,7 @@
viewerNote, viewerNote,
notebookSortMode, notebookSortMode,
notebookOrder, notebookOrder,
noteOrder,
syncState, syncState,
platformIsMobile platformIsMobile
} from '$lib/stores/app'; } from '$lib/stores/app';
@@ -206,6 +207,7 @@
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode, notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder, notebook_order: $notebookOrder,
note_order: $noteOrder,
sort_mode: $sortMode, sort_mode: $sortMode,
last_view_mode: $viewMode, last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null, last_notebook: $activeNotebook?.relative_path ?? null,
@@ -528,6 +530,11 @@
persistState(); persistState();
}); });
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => { $effect(() => {
$sortMode; $sortMode;
$tasksLayout; $tasksLayout;
@@ -558,7 +565,8 @@
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical'; $notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {}; $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 (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_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged; if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
+99 -3
View File
@@ -13,6 +13,7 @@
quickAccessPaths, quickAccessPaths,
appConfig, appConfig,
notelistCollapsed, notelistCollapsed,
noteOrder,
notebooks, notebooks,
tags, tags,
mobileView mobileView
@@ -206,6 +207,11 @@
let qaDragFrom = $state<number | null>(null); let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null); let qaDragOver = $state<number | null>(null);
let qaDragHalf = $state<'top' | 'bottom'>('bottom'); let qaDragHalf = $state<'top' | 'bottom'>('bottom');
let noteDragFrom = $state<number | null>(null);
let noteDragOver = $state<number | null>(null);
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
const customOrderViews = new Set(['all', 'notebook', 'tag']);
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
// Virtual scroll // Virtual scroll
let listContainer = $state<HTMLDivElement>(null!); let listContainer = $state<HTMLDivElement>(null!);
@@ -415,6 +421,7 @@
} }
try { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear(); noteCache.clear();
await refresh(); await refresh();
await selectNote(entry); await selectNote(entry);
@@ -432,6 +439,7 @@
try { try {
const newTitle = editValue.trim(); const newTitle = editValue.trim();
const newPath = await renameNote(note.path, newTitle); const newPath = await renameNote(note.path, newTitle);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear(); noteCache.clear();
editingNote = null; editingNote = null;
// Update local store immediately (avoid full vault re-scan) // Update local store immediately (avoid full vault re-scan)
@@ -453,6 +461,7 @@
contextMenu = null; contextMenu = null;
try { try {
await deleteNote(note.path); await deleteNote(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear(); noteCache.clear();
// Remove from local store immediately (avoid full vault re-scan) // Remove from local store immediately (avoid full vault re-scan)
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
@@ -480,6 +489,7 @@
contextMenu = null; contextMenu = null;
try { try {
await permanentDelete(note.path); await permanentDelete(note.path);
removeManualNoteOrderPath(note.path);
noteCache.clear(); noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === 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) { async function handleMoveNote(note: NoteEntry, destPath: string) {
contextMenu = null; contextMenu = null;
movePickerNote = null; movePickerNote = null;
try { try {
const newPath = await moveNote(note.path, destPath); const newPath = await moveNote(note.path, destPath);
renameManualNoteOrderPath(note.path, newPath);
noteCache.clear(); noteCache.clear();
$notes = $notes.filter(n => n.path !== note.path); $notes = $notes.filter(n => n.path !== note.path);
if ($activeNotePath === note.path) { if ($activeNotePath === note.path) {
@@ -779,6 +838,7 @@
async function handleBatchDelete() { async function handleBatchDelete() {
const toDelete = new Set(selectedPaths); const toDelete = new Set(selectedPaths);
noteCache.clear(); noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path)); $notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) { if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null; $activeNote = null;
@@ -814,6 +874,7 @@
async function handleBatchPermanentDelete() { async function handleBatchPermanentDelete() {
const toDelete = new Set(selectedPaths); const toDelete = new Set(selectedPaths);
noteCache.clear(); noteCache.clear();
for (const path of toDelete) removeManualNoteOrderPath(path);
$notes = $notes.filter(n => !toDelete.has(n.path)); $notes = $notes.filter(n => !toDelete.has(n.path));
if ($activeNotePath && toDelete.has($activeNotePath)) { if ($activeNotePath && toDelete.has($activeNotePath)) {
$activeNote = null; $activeNote = null;
@@ -839,6 +900,9 @@
} }
function setSortMode(mode: SortMode) { function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode; $sortMode = mode;
sortMenu = null; sortMenu = null;
} }
@@ -1089,6 +1153,8 @@
class:compact={compact} class:compact={compact}
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'} 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: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)} onclick={(e) => handleNoteClick(e, note)}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'F2') { if (e.key === 'F2') {
@@ -1122,6 +1188,12 @@
e.dataTransfer!.effectAllowed = 'move'; e.dataTransfer!.effectAllowed = 'move';
return; 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)) { 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 {
@@ -1136,18 +1208,28 @@
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom'; qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
qaDragOver = noteIndex; 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={() => { ondragleave={() => {
if (qaDragOver === noteIndex) qaDragOver = null; if (qaDragOver === noteIndex) qaDragOver = null;
if (noteDragOver === noteIndex) noteDragOver = null;
}} }}
ondrop={(e) => { ondrop={(e) => {
if ($viewMode === 'quickaccess' && qaDragFrom !== null) { if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
e.preventDefault(); e.preventDefault();
handleQaDrop(noteIndex); handleQaDrop(noteIndex);
} else if (canCustomReorder && noteDragFrom !== null) {
e.preventDefault();
handleCustomNoteDrop(noteIndex);
} }
}} }}
ondragend={() => { qaDragFrom = null; qaDragOver = null; }} ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
> >
{#if compact} {#if compact}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1426,6 +1508,18 @@
Title Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if} {#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</button> </button>
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6" />
<line x1="8" y1="12" x2="21" y2="12" />
<line x1="8" y1="18" x2="21" y2="18" />
<circle cx="4" cy="6" r="1" />
<circle cx="4" cy="12" r="1" />
<circle cx="4" cy="18" r="1" />
</svg>
Custom
{#if $sortMode === 'custom'}<span class="sort-check">&#10003;</span>{/if}
</button>
</div> </div>
{/if} {/if}
@@ -1948,11 +2042,13 @@
font-size: 14px; font-size: 14px;
} }
.note-item.qa-drag-above { .note-item.qa-drag-above,
.note-item.note-drag-above {
border-top: 2px solid var(--accent); 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); border-bottom: 2px solid var(--accent);
} }
+20 -2
View File
@@ -40,6 +40,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
); );
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'. // Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
export const notebookOrder = writable<Record<string, number>>({}); export const notebookOrder = writable<Record<string, number>>({});
export const noteOrder = writable<Record<string, number>>({});
// Data // Data
export const notebooks = writable<NotebookEntry[]>([]); export const notebooks = writable<NotebookEntry[]>([]);
@@ -177,16 +178,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
// Derived // Derived
export const sortedNotes = derived( export const sortedNotes = derived(
[notes, sortMode, viewMode], [notes, sortMode, viewMode, noteOrder],
([$notes, $sortMode, $viewMode]) => { ([$notes, $sortMode, $viewMode, $noteOrder]) => {
// Quick Access preserves stored order // Quick Access preserves stored order
if ($viewMode === "quickaccess") return $notes; if ($viewMode === "quickaccess") return $notes;
const customSortable =
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
const pinned = $notes.filter((n) => n.meta.pinned); const pinned = $notes.filter((n) => n.meta.pinned);
const unpinned = $notes.filter((n) => !n.meta.pinned); const unpinned = $notes.filter((n) => !n.meta.pinned);
const sortFn = (a: NoteEntry, b: NoteEntry) => { const sortFn = (a: NoteEntry, b: NoteEntry) => {
switch ($sortMode) { 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": case "title":
return a.meta.title.localeCompare(b.meta.title); return a.meta.title.localeCompare(b.meta.title);
case "created": case "created":
@@ -217,6 +232,7 @@ export const vaultState = derived(
collapsedNotebooks, collapsedNotebooks,
notebookSortMode, notebookSortMode,
notebookOrder, notebookOrder,
noteOrder,
], ],
([ ([
$activeNotePath, $activeNotePath,
@@ -227,6 +243,7 @@ export const vaultState = derived(
$collapsedNotebooks, $collapsedNotebooks,
$notebookSortMode, $notebookSortMode,
$notebookOrder, $notebookOrder,
$noteOrder,
]) => { ]) => {
return { return {
last_open_note: $activeNotePath, last_open_note: $activeNotePath,
@@ -237,6 +254,7 @@ export const vaultState = derived(
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode, notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder, notebook_order: $notebookOrder,
note_order: $noteOrder,
} satisfies VaultState; } satisfies VaultState;
}, },
); );
+2 -1
View File
@@ -109,6 +109,7 @@ export interface VaultState {
collapsed_notebooks: string[]; collapsed_notebooks: string[];
notebook_sort_mode?: string; notebook_sort_mode?: string;
notebook_order?: Record<string, number>; notebook_order?: Record<string, number>;
note_order?: Record<string, number>;
sort_mode?: string; sort_mode?: string;
last_view_mode?: string; last_view_mode?: string;
last_notebook?: string | null; last_notebook?: string | null;
@@ -167,7 +168,7 @@ export interface NoteTitleEntry {
path: string; path: string;
} }
export type SortMode = "modified" | "title" | "created"; export type SortMode = "modified" | "title" | "created" | "custom";
export type ViewMode = export type ViewMode =
| "all" | "all"
| "notebook" | "notebook"