Chapters as rows, in two views, in their own colours

Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 14:20:14 +02:00
1 parent 6cad632a0c
commit d74fea4905
12 files changed
+640 -88

No files matched your search

+67 -26
View File
@@ -12,7 +12,7 @@
* scramble everything that was not being looked at.
*/
import type { NoteMeta, Separator, SortMode } from "../../types";
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
@@ -86,6 +86,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
*/
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 };
/**
@@ -94,51 +95,90 @@ export type Entry =
* 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[] = []): Entry[] {
/* Notes are placed by their rank, not by their stored number. Notes that have
* never been dragged all carry 0, and sorting has already broken that tie —
* using the stored numbers here would pile them all at position 0 and push
* every separator behind the lot. */
const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({
kind: "note" as const,
id: note.id,
order: rank,
note,
}));
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, noteEntries.length)),
order: Math.max(0, Math.min(separator.order, rowEntries.length)),
separator,
}));
return [...noteEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a note comes first, so
// "a line above this note" lands where it was asked for.
return [...rowEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a row comes first, so
// "a line above this one" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
}
/** The result of a drag: what to write back for notes and for separators. */
/** The result of a drag: what to write back for each kind of row. */
export interface Arrangement {
noteIds: string[];
/** 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 noteIds: string[] = [];
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") noteIds.push(entry.id);
// A separator's position is "how many notes come before me". Expressed that
// way it stays put as the notes around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: noteIds.length });
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 { noteIds, separators };
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
}
/**
@@ -151,10 +191,11 @@ export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string
targetId: string,
chapters: Chapter[] = []
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators);
const entries = mergeEntries(notes, separators, chapters);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;