Chapters: folders inside a notebook

A note is in exactly one chapter, or in none — which means the notebook
itself. Chapters sit above the loose notes; tapping one opens its own
list and the menu button becomes the way back out, which on a phone is
the only way, since the list fills the screen.

A chapter is not a wastebasket. Removing one never removes what it held:
the notes come back to the notebook, where they can be seen and filed
again. That is ON DELETE SET NULL, and it is also written out in the
delete route, because it is the promise the whole thing rests on and
should be visible where it happens rather than inferred from a schema.
The dialog says so too, with the number of notes affected, and the suite
removes a chapter holding three notes and asks the server for all three.

Three rules keep the two levels honest. A note filed into a chapter
leaves the notebook's own list, or filing it would appear to do nothing.
A chapter belongs to its notebook, so the server refuses to file a note
into a chapter of a book it is not in, and moving a note to another
notebook clears its chapter. And separators belong to the list they were
drawn in: a separator's order counts the notes before it, which means
nothing across lists.

Two bugs found while building it, both a day old and both affecting the
desktop rather than the phone they were written for:

- A submenu was clipped away. The context menu had been given
  `overflow-y: auto`, which cuts off anything flying out sideways — so
  the notebooks under "Move to..." were invisible and every click on them
  landed on the note list behind. The side was inverted too:
  `right: 100%` places a panel on the left.
- A separator was a one-pixel hairline in the border colour with faint
  grey text on near-black. Present, and on a phone effectively invisible.

And one test that never measured what it claimed. It asked whether a
dragged note had become "the most recently changed" by reading the order
on screen — but three notes made seconds apart share an `updated_at`
second, so that order is a tie broken arbitrarily. It passed on luck for
weeks and fell over on a run a fraction faster. It now compares the
timestamps themselves, before and after the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 13:04:18 +02:00
1 parent e037f01dc2
commit 1757ec9af3
15 files changed
+1078 -78

No files matched your search

+106 -6
View File
@@ -174,6 +174,54 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
);
}
/**
* A chapter, in the list of the notebook it belongs to.
*
* Shown above the loose notes rather than mixed in with them: a chapter is a
* place, and the notes are what is in this one. The count is what makes it worth
* opening — a row saying only "Invoices" tells you nothing about whether to.
*/
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
return (
<button
data-chapter-row={chapter.id}
onClick={() => onOpen(chapter.id)}
onContextMenu={(e) => onContextMenu(e, chapter)}
style={{
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${T.borderLight}`,
background: "transparent",
cursor: "pointer",
marginBottom: 4,
textAlign: "left",
}}
>
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 13,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{chapter.name}
</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
</button>
);
}
/**
* How this notebook's notes are ordered.
*
@@ -370,6 +418,11 @@ export function NoteList({
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
chapters,
openChapter,
onOpenChapter,
onAddChapter,
onChapterContextMenu,
onReorder,
onToggleSidebar,
onSelectNote,
@@ -383,6 +436,11 @@ export function NoteList({
browsers never fire from touch — on a phone the arrangement could not be
changed at all, with nothing on screen to say why. */
const dnd = useReorderDrag(!!reorderable, onReorder);
/* Chapters divide a notebook, so they are offered in one — not in "All Notes"
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
const canAddChapter = !!onAddChapter && !openChapter;
const shownChapters = openChapter ? [] : chapters || [];
return (
<div
@@ -407,8 +465,13 @@ export function NoteList({
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
{/* Inside a chapter this is the way back out to the notebook — which
on a phone is the only way, since the chapter fills the screen. */}
<button
onClick={onToggleSidebar}
onClick={openChapter ? () => onOpenChapter(null) : onToggleSidebar}
title={openChapter ? "Back to the notebook" : "Menu"}
aria-label={openChapter ? "Back to the notebook" : "Menu"}
data-chapter-back={openChapter ? "on" : undefined}
style={{
display: "flex",
alignItems: "center",
@@ -419,10 +482,10 @@ export function NoteList({
border: "none",
background: "transparent",
cursor: "pointer",
color: T.muted,
color: openChapter ? T.accent : T.muted,
}}
>
{I.menu}
{openChapter ? I.back : I.menu}
</button>
<h2
style={{
@@ -436,7 +499,7 @@ export function NoteList({
whiteSpace: "nowrap",
}}
>
{title}
{openChapter ? openChapter.name : title}
</h2>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
@@ -490,7 +553,7 @@ export function NoteList({
onQueryChange("");
e.currentTarget.blur();
}}
placeholder={`Search in ${title}...`}
placeholder={`Search in ${openChapter ? openChapter.name : title}...`}
aria-label="Search in this notebook"
style={{
width: "100%",
@@ -508,15 +571,40 @@ export function NoteList({
{/* Just the one control. The sentence that used to sit here explained the
order the sort menu already names, on every visit, forever. */}
{reorderable && (
{(reorderable || canAddChapter) && (
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 6,
padding: "5px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{/* A chapter divides the notebook; a separator divides a list. They
sit together because both are "add something that is not a note",
and apart from the + button, which makes notes. */}
{canAddChapter && (
<button
onClick={onAddChapter}
data-add-chapter
style={{
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
borderRadius: 8,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
fontSize: 11.5,
fontWeight: 600,
}}
>
+ Chapter
</button>
)}
<button
onClick={onAddSeparator}
data-add-separator
@@ -585,6 +673,18 @@ export function NoteList({
something there: in "newest first" the notes would slide past a
line on every edit. They are not shown in the other orders, and
are still in place on the way back. */}
{shownChapters.map((chapter) => (
<ChapterRow
key={chapter.id}
T={T}
chapter={chapter}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
))}
{shownChapters.length > 0 && notes.length > 0 && (
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
)}
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
+17 -4
View File
@@ -174,10 +174,23 @@ export function reindexEntries(
export const END_ID = "__end__";
/** A new separator, added at the top where it can be seen and then dragged. */
export const withNewSeparator = (separators: Separator[], id: string, label = ""): Separator[] => [
{ id, label, order: 0 },
...separators,
];
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.