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

+142 -18
View File
@@ -12,6 +12,7 @@ import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
import { NameDialog } from "./ui/NameDialog.jsx";
import { ContextMenu } from "./ui/ContextMenu.jsx";
import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx";
import { AccountMenu } from "./ui/AccountMenu.jsx";
@@ -28,6 +29,7 @@ import {
canReorder,
sortModesFor,
sortNotes,
separatorsFor,
withNewSeparator,
} from "./features/notes/noteSort";
import { EditorPane } from "./features/notes/EditorPane.jsx";
@@ -61,6 +63,10 @@ export default function App() {
const [activeFolder, setActiveFolder] = useState("dashboard");
const [activeNote, setActiveNote] = useState(null);
/* Which chapter of the open notebook is being looked into; null is the
notebook itself. A chapter is a folder inside a notebook, so this is a
second step of "where the user is", not a second notebook. */
const [activeChapter, setActiveChapter] = useState(null);
const [searchQuery, setSearchQuery] = useState("");
const [showNewNb, setShowNewNb] = useState(false);
@@ -82,6 +88,8 @@ export default function App() {
const [renamingSeparator, setRenamingSeparator] = useState(null);
const [historyFor, setHistoryFor] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
// { title, value, onSave } while a chapter is being named or renamed.
const [namingChapter, setNamingChapter] = useState(null);
const [confirmDialog, setConfirmDialog] = useState(null);
// Bumped on a version restore to force a fresh editor on the restored body.
const [restoreEpoch, setRestoreEpoch] = useState(0);
@@ -101,8 +109,8 @@ export default function App() {
whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
const rearrangeNotes = useCallback(
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId),
[data, activeFolder]
(dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter),
[data, activeFolder, activeChapter]
);
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
@@ -151,6 +159,7 @@ export default function App() {
const openFolder = useCallback(
(id) => {
setActiveFolder(id);
setActiveChapter(null);
setActiveNote(null);
if (isMobile) {
setSidebarOpen(false);
@@ -186,6 +195,16 @@ export default function App() {
/* ── Derived note lists ── */
const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]);
/* Chapters of the notebook in hand, with how many notes each holds — the
count is what makes a chapter worth opening rather than a guess. */
const folderChapters = useMemo(() => {
const list = data.chapters.filter((c) => c.notebookId === activeFolder);
return list.map((c) => ({
...c,
noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length,
}));
}, [data.chapters, activeFolder, notes]);
const openChapter = folderChapters.find((c) => c.id === activeChapter) || null;
const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]);
const isTrashView = activeFolder === "trash";
@@ -211,14 +230,25 @@ export default function App() {
/* ── Separators ──
* Lines the user draws between notes. They belong to the notebook's own
* arrangement, so they are stored on it and only ever shown in manual order. */
const separators = activeNotebook?.separators || [];
const allSeparators = activeNotebook?.separators || [];
/* Only the ones drawn in the list being looked at — the notebook itself, or
the chapter that is open. */
const separators = useMemo(
() => separatorsFor(allSeparators, activeChapter),
[allSeparators, activeChapter]
);
const writeSeparators = (next) =>
activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next });
/* Written back whole: the notebook stores one list, and the ones belonging to
the other chapters have to survive a change made in this one. */
const writeVisible = (visible) =>
writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]);
const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`));
const addSeparator = () =>
writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter));
const renameSeparator = (id, label) =>
writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id));
writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s)));
const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id));
const separatorContextMenu = (e, separator) => {
e.preventDefault();
@@ -254,7 +284,11 @@ export default function App() {
if (activeFolder === "trash") list = trashedNotes;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else list = activeNotes.filter((n) => n.folderId === activeFolder);
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
@@ -268,7 +302,7 @@ export default function App() {
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
}, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -404,7 +438,11 @@ export default function App() {
const createNote = () =>
withNotebook("New note", async (folderId) => {
const note = await data.createNote(folderId);
/* A note made while a chapter is open belongs to that chapter — but only
if it is a chapter of the notebook being written into, which it is not
when the notebook had to be asked for. */
const chapterId = folderId === activeFolder ? activeChapter : null;
const note = await data.createNote(folderId, chapterId);
if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId);
openNote(note.id);
});
@@ -503,16 +541,86 @@ export default function App() {
});
};
/** "Move to..." lists every notebook except the one the note is already in. */
/**
* "Move to..." — the other notebooks, and the chapters of this one.
*
* Both in one list because both answer the same question: where should this
* note live. A chapter of the notebook it is already in is one step; another
* notebook is another, and takes it out of whatever chapter it was in, since
* a chapter belongs to the book it is in.
*/
const moveSubmenu = (note) => {
const targets = notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: coverBackground(nb.cover, nb.coverPattern),
action: () => data.moveNote(note.id, nb.id),
}));
return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId);
const targets = [
...(note.chapterId
? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }]
: []),
...here.map((c) => ({
label: c.name,
icon: I.books,
action: () => data.moveNote(note.id, note.folderId, c.id),
})),
...(here.length || note.chapterId ? [{ divider: true }] : []),
...notebooks
.filter((nb) => nb.id !== note.folderId)
.map((nb) => ({
label: nb.name,
colorDot: coverBackground(nb.cover, nb.coverPattern),
action: () => data.moveNote(note.id, nb.id, null),
})),
];
// A lone divider is not a menu.
const usable = targets.filter((t) => !t.divider);
return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : [];
};
/* ── Chapters ──
* Folders inside a notebook. Named on creation, because a chapter with no name
* is a row nobody can tell from the next one — unlike a separator, which is a
* line and reads perfectly well unnamed. */
const addChapter = () => {
if (!activeNotebook) return;
setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) });
};
const chapterContextMenu = (e, chapter) => {
e.preventDefault();
e.stopPropagation();
setCtxMenu({
x: e.clientX,
y: e.clientY,
items: [
{
label: "Rename",
icon: I.edit,
action: () =>
setNamingChapter({
title: "Rename chapter",
value: chapter.name,
onSave: (name) => data.renameChapter(chapter.id, name),
}),
},
{ divider: true },
{
label: "Remove chapter",
icon: I.trash,
danger: true,
action: () =>
setConfirmDialog({
title: "Remove chapter?",
message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${
chapter.noteCount === 1 ? "" : "s"
} stay — they go back to the notebook.`,
confirmLabel: "Remove",
danger: true,
onConfirm: () => {
if (activeChapter === chapter.id) setActiveChapter(null);
void data.deleteChapter(chapter.id);
},
}),
},
],
});
};
const noteContextMenu = (e, note) => {
@@ -663,6 +771,11 @@ export default function App() {
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
openChapter={openChapter}
onOpenChapter={setActiveChapter}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
@@ -787,6 +900,17 @@ export default function App() {
)}
/>
)}
{namingChapter && (
<NameDialog
title={namingChapter.title}
value={namingChapter.value}
label="Chapter name"
confirmLabel={namingChapter.value ? "Rename" : "Create"}
onSave={namingChapter.onSave}
onClose={() => setNamingChapter(null)}
dark={dark}
/>
)}
{confirmDialog && (
<ConfirmDialog
title={confirmDialog.title}