/** * 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 = { 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 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;