Add custom note sorting

This commit is contained in:
mf
2026-06-21 10:46:22 +03:30
parent db2bef892a
commit e46fda91a4
5 changed files with 130 additions and 7 deletions
+3
View File
@@ -267,6 +267,8 @@ pub struct VaultState {
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub note_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String,
#[serde(default)]
pub last_view_mode: String,
@@ -294,6 +296,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,
+9 -1
View File
@@ -48,6 +48,7 @@
viewerNote,
notebookSortMode,
notebookOrder,
noteOrder,
syncState,
platformIsMobile
} from '$lib/stores/app';
@@ -203,6 +204,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,
@@ -518,6 +520,11 @@
persistState();
});
$effect(() => {
$noteOrder;
persistState();
});
$effect(() => {
$sortMode;
$tasksLayout;
@@ -547,7 +554,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;
+96 -3
View File
@@ -12,6 +12,7 @@
editorDirty,
quickAccessPaths,
appConfig,
noteOrder,
notebooks,
tags,
mobileView
@@ -205,6 +206,11 @@
let qaDragFrom = $state<number | null>(null);
let qaDragOver = $state<number | null>(null);
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
let listContainer = $state<HTMLDivElement>(null!);
@@ -414,6 +420,7 @@
}
try {
const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear();
await refresh();
await selectNote(entry);
@@ -431,6 +438,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)
@@ -452,6 +460,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);
@@ -479,6 +488,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) {
@@ -672,11 +682,57 @@
}
}
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
$noteOrder = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
}
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) {
@@ -778,6 +834,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;
@@ -813,6 +870,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;
@@ -838,6 +896,9 @@
}
function setSortMode(mode: SortMode) {
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
persistManualNoteOrder($sortedNotes);
}
$sortMode = mode;
sortMenu = null;
}
@@ -1078,6 +1139,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') {
@@ -1111,6 +1174,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 {
@@ -1125,18 +1194,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}
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1415,6 +1494,18 @@
Title
{#if $sortMode === 'title'}<span class="sort-check">&#10003;</span>{/if}
</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>
{/if}
@@ -1937,11 +2028,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);
}
+20 -2
View File
@@ -39,6 +39,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<Record<string, number>>({});
export const noteOrder = writable<Record<string, number>>({});
// Data
export const notebooks = writable<NotebookEntry[]>([]);
@@ -176,16 +177,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":
@@ -215,6 +230,7 @@ export const vaultState = derived(
collapsedNotebooks,
notebookSortMode,
notebookOrder,
noteOrder,
],
([
$activeNotePath,
@@ -224,6 +240,7 @@ export const vaultState = derived(
$collapsedNotebooks,
$notebookSortMode,
$notebookOrder,
$noteOrder,
]) => {
return {
last_open_note: $activeNotePath,
@@ -233,6 +250,7 @@ export const vaultState = derived(
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder,
note_order: $noteOrder,
} satisfies VaultState;
},
);
+2 -1
View File
@@ -108,6 +108,7 @@ export interface VaultState {
collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
note_order?: Record<string, number>;
sort_mode?: string;
last_view_mode?: string;
last_notebook?: string | null;
@@ -166,7 +167,7 @@ export interface NoteTitleEntry {
path: string;
}
export type SortMode = "modified" | "title" | "created";
export type SortMode = "modified" | "title" | "created" | "custom";
export type ViewMode =
| "all"
| "notebook"