Files
InkNote/frontend/src/features/notes/noteSort.ts
T
marcandClaude Opus 5 f01522aa6f A chapter is a list too, and deleting stops moving things
Deleting a note moved everything else. A row leaving is the same problem as
a row arriving, in reverse: a line's position counts the rows above it, so a
note deleted above a line left the line counting rows that were no longer
there, and everything under it appeared to jump up past it. withoutRow
renumbers the list a row leaves, and every place one leaves says so -- the
bin, a move, a chapter being removed. Arrivals land at the top: an old
position belongs to an arrangement that has moved on.

A line could not be dragged under another line. Two lines in the same gap
carry the same position, and the comparator claimed "a separator comes
first" without asking what the other one was -- both directions at once for
that case. A comparator that contradicts itself has no defined result, so
the sort returned them in whichever order it happened to walk.

A chapter is always in the user's own order and can be arranged like any
other list: its notes wear handles under the same switch as the list beside
them and are dragged among themselves, strips at both ends included. The two
lists keep apart by scope -- a chapter unfolded under its row holds rows
inside a row of the notebook's list, so the nearest row above the pointer
cannot say which list a drag belongs to. The sort menu is gone inside a
chapter: the mode belongs to the notebook, and a menu there would offer a
choice that cannot be honoured.

The chapters suite no longer assumes the first chapter row is the one that
matters. A chapter is a row in the arrangement and can be anywhere in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 12:37:02 +02:00

358 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The order notes appear in inside a notebook.
*
* Kept as a pure function so the awkward part can be tested: the promise that
* switching away to another order — or searching, which imposes its own — and
* then switching back leaves the user's own arrangement exactly as they left it.
*
* That promise is only keepable if the manual order lives in the notes and is
* never re-derived from whatever happens to be on screen. Hence `reindex`, which
* takes the *whole* notebook and refuses to renumber a filtered subset: doing so
* would give a hidden note the same position as a visible one and quietly
* scramble everything that was not being looked at.
*/
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
/**
* What a notebook is in until it is told otherwise.
*
* The hand-made order, because that is where a notebook's own arrangement lives:
* separators show, rows can be dragged into place, and nothing slides about on
* its own. A notebook that has never been arranged loses nothing by it —
* untouched rows all carry position 0 and fall back to newest first, which is
* what the old default showed.
*/
export const DEFAULT_NOTEBOOK_SORT: SortMode = "manual";
/**
* What a *view* is in — "All Notes", "Starred", the bin.
*
* Not the hand-made one: those are questions asked of every notebook at once, so
* there is nothing for an arrangement to belong to and nowhere to keep it.
*/
export const DEFAULT_SORT: SortMode = "updated";
export const SORT_LABELS: Record<SortMode, string> = {
manual: "My own order",
updated: "Last changed",
oldest: "Oldest first",
created: "Newest created",
title: "Title A–Z",
};
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
/**
* The orders a view can be put in.
*
* "All Notes" and "Starred" are not places, they are questions asked of every
* notebook at once — so there is nothing for a hand-made arrangement to belong
* to, and nowhere to keep it. Offering it there would be offering a setting that
* cannot be honoured.
*/
export const sortModesFor = (isNotebook: boolean): SortMode[] =>
isNotebook ? SORT_MODES : SORT_MODES.filter((m) => m !== "manual");
const time = (value: string | undefined) => {
const t = Date.parse(value ?? "");
return Number.isNaN(t) ? 0 : t;
};
const byTitle = (a: NoteMeta, b: NoteMeta) =>
(a.title || "").localeCompare(b.title || "", "de", { sensitivity: "base", numeric: true });
/**
* Sorts a list without mutating it.
*
* Every comparison ends in a tie-break that cannot itself tie: two notes created
* in the same second, or two untouched imports that all carry position 0, must
* still come out in the same order on every render, or the list shuffles under
* the cursor as React re-renders.
*/
export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): NoteMeta[] {
const stable = (a: NoteMeta, b: NoteMeta) => a.id.localeCompare(b.id);
const compare: Record<SortMode, (a: NoteMeta, b: NoteMeta) => number> = {
// Notes that have never been dragged share position 0; newest first within
// that group, so a fresh note appears at the top rather than at random.
manual: (a, b) =>
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
updated: (a, b) => time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
oldest: (a, b) => time(a.updatedAt) - time(b.updatedAt) || stable(a, b),
created: (a, b) => time(b.createdAt) - time(a.createdAt) || stable(a, b),
title: (a, b) => byTitle(a, b) || stable(a, b),
};
return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]);
}
/* ── separators ── */
/**
* A line drawn between notes to group them.
*
* Separators live in the same order as the notes and are dragged the same way,
* because a divider that cannot be moved with what it divides is worse than
* none. They belong to the notebook rather than to any note: they are part of
* the arrangement, and the arrangement is the notebook's.
*
* They exist only in the manual order. In "newest first" there is nothing for a
* line to mean — the notes would slide past it on every edit — so the other
* orders simply do not show them, and they are still there on the way back.
*/
export type Entry =
| { kind: "note"; id: string; order: number; note: NoteMeta }
| { kind: "chapter"; id: string; order: number; chapter: Chapter }
| { kind: "separator"; id: string; order: number; separator: Separator };
/**
* Notes and separators, interleaved into the one order the user arranged.
*
* A separator sitting at the same position as a note comes first, so "insert a
* line above this note" lands where it was asked for rather than one row late.
*/
export function mergeEntries(
notes: NoteMeta[],
separators: Separator[] = [],
chapters: Chapter[] = []
): Entry[] {
/* Notes and chapters share one sequence.
*
* A chapter is a row in the list like a note is, and can be dragged among
* them, so they cannot each be numbered from zero — they are ranked together
* by the position each carries.
*
* Ranked, not placed by the stored number: rows that have never been dragged
* all carry 0, and using the raw numbers would pile them at position 0 and
* push every separator behind the lot. */
const ranked = sortNotes(notes, "manual");
const rows = [
...chapters.map((chapter, i) => ({
kind: "chapter" as const,
id: chapter.id,
chapter,
sort: chapter.sortOrder ?? 0,
// Keeps the order within one kind settled when several share a position,
// which every row does until it has been dragged once.
tie: i,
})),
...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })),
].sort(
(a, b) =>
a.sort - b.sort ||
// A chapter shares a position with a note only before either has been
// moved; putting chapters first there matches where they are created.
(a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1)
);
const rowEntries: Entry[] = rows.map((row, rank) =>
row.kind === "chapter"
? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter }
: { kind: "note", id: row.id, order: rank, note: row.note }
);
const separatorEntries: Entry[] = separators.map((separator) => ({
kind: "separator" as const,
id: separator.id,
// Clamped, so a separator left over from a notebook that has since shrunk
// still appears — at the end — rather than vanishing with its label.
order: Math.max(0, Math.min(separator.order, rowEntries.length)),
separator,
}));
return [...rowEntries, ...separatorEntries].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
/* Two lines in the same gap keep the order they were written in.
*
* This used to say "a separator comes first" without asking what the other
* one was — so for two separators it claimed *both* directions at once. A
* comparator that contradicts itself has no defined result, and the sort
* returned them in whichever order it happened to walk: a line dragged under
* another sprang back above it, every time. */
if (a.kind === "separator" && b.kind === "separator") return 0;
// A separator sitting at the same position as a row comes first, so
// "a line above this one" lands where it was asked for.
return a.kind === "separator" ? -1 : 1;
});
}
/** The result of a drag: what to write back for each kind of row. */
export interface Arrangement {
/** Notes in their new order, with the position each should carry. */
notes: Array<{ id: string; order: number }>;
/** Chapters likewise — numbered from the *same* counter as the notes. */
chapters: Array<{ id: string; order: number }>;
separators: Separator[];
/** The note ids alone, for callers that only reorder notes. */
noteIds: string[];
}
/** Turns an ordered entry list back into storable positions. */
export function toArrangement(entries: Entry[]): Arrangement {
const notes: Array<{ id: string; order: number }> = [];
const chapters: Array<{ id: string; order: number }> = [];
const separators: Separator[] = [];
/* One counter for notes and chapters together. Numbering them separately
* would leave two sequences that cannot be interleaved again, and a chapter
* dragged between two notes would spring back. */
let position = 0;
for (const entry of entries) {
if (entry.kind === "note") notes.push({ id: entry.id, order: position++ });
else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ });
// A separator's position is "how many rows come before me". Expressed that
// way it stays put as the rows around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: position });
}
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
}
/**
* The whole notebook rearranged after something was dragged onto something else.
*
* `notes` must be every note in the notebook, not the filtered view. Returns
* null when the move changes nothing, which spares the caller a pointless write.
*
* A drop lands the row **under** the one it was released on, and only ever
* there. Dropping above a row and dropping below the row before it are the same
* move described twice, and having both meant every gap could be aimed at from
* two sides — which is a hard thing to aim at and a harder thing to read. The
* two strips, one above the first row and one below the last, are how the ends
* are reached.
*/
export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string,
chapters: Chapter[] = []
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators, chapters);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;
const [moved] = entries.splice(from, 1);
if (!moved) return null;
if (targetId === START_ID) entries.unshift(moved);
else if (targetId === END_ID) entries.push(moved);
else {
// Found after the row has been lifted out, so the index is the one in the
// list the row is being dropped into.
const to = entries.findIndex((e) => e.id === targetId);
if (to < 0) return null;
entries.splice(to + 1, 0, moved);
}
return toArrangement(entries);
}
/**
* The whole list renumbered with one row put at the very top.
*
* For a note that has just been made. It used to be given no position at all and
* left to the tie-break, which put it near the top and moved everything else:
* a separator's position counts the rows above it, so a row appearing at the top
* without anybody being renumbered left every line one row too high — the
* notebook's arrangement came apart a little more with each new note.
*
* Renumbering the list settles that, and settles it for good: rows that were
* never dragged all carried position 0 and were being ordered by how recently
* they changed, so they slid about on their own. Afterwards each keeps the place
* it is in — which is what a hand-made order is for.
*/
export function placeOnTop(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
ids: string | string[]
): Arrangement | null {
const wanted = Array.isArray(ids) ? ids : [ids];
const arriving = wanted
.map((id) => notes.find((n) => n.id === id))
.filter((n): n is NoteMeta => !!n);
if (arriving.length === 0) return null;
/* Built from the list *without* the new notes, then those put in front.
*
* Merging them in first would be measuring the room after the furniture
* moved: a new row already counts as a row, so every line reads as sitting
* one place further down than it was drawn, and renumbering would write that
* misreading back as the truth. */
const held = new Set(arriving.map((n) => n.id));
const entries = mergeEntries(
notes.filter((n) => !held.has(n.id)),
separators,
chapters
);
entries.unshift(...arriving.map((note) => ({ kind: "note" as const, id: note.id, order: 0, note })));
return toArrangement(entries);
}
/**
* The whole list renumbered with one row taken out of it.
*
* For a note being thrown away or moved elsewhere. A row leaving is the same
* problem as a row arriving, in reverse: a separator's position counts the rows
* above it, so a note deleted above a line left that line one row too *low*, and
* everything below the line appeared to jump up past it. Nothing here moves
* except what is leaving.
*
* `notes` must still contain the row — it is the list as it is drawn, which is
* what the lines were counted against.
*/
export function withoutRow(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
id: string
): Arrangement | null {
const entries = mergeEntries(notes, separators, chapters);
const at = entries.findIndex((e) => e.id === id);
if (at < 0) return null;
entries.splice(at, 1);
return toArrangement(entries);
}
/** Drop target standing for "after everything". */
export const END_ID = "__end__";
/** And its opposite: the one place a drop under a row cannot reach. */
export const START_ID = "__start__";
/** A new separator, added at the top where it can be seen and then dragged. */
export const withNewSeparator = (
separators: Separator[],
id: string,
label = "",
chapterId: string | null = null
): Separator[] => [{ id, label, order: 0, chapterId }, ...separators];
/**
* The separators belonging to one list.
*
* A separator's `order` counts the notes before it, which only means anything
* inside the list it was drawn in — the notebook itself, or one of its chapters.
* Showing all of a notebook's separators in every chapter would put lines in
* places nobody drew them.
*/
export const separatorsFor = (separators: Separator[], chapterId: string | null): Separator[] =>
separators.filter((s) => (s.chapterId ?? null) === chapterId);
/**
* The full notebook renumbered after a note was dragged onto another.
*
* Kept for the case with no separators; `reindexEntries` is the general one.
*/
export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null {
return reindexEntries(all, [], dragId, targetId)?.noteIds ?? null;
}
/**
* Whether the manual order may be changed right now.
*
* Dragging while a search is narrowing the list would reorder against a view the
* hidden notes are not part of — so it is simply not offered, rather than being
* offered and then producing an order nobody arranged.
*/
export const canReorder = (mode: SortMode, filtering: boolean) => mode === "manual" && !filtering;