A chapter with an order of its own

A chapter orders its own notes now, chosen in the same dialog as its name,
mark and colour -- one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and
filled, so a reference chapter can stay alphabetical while the notebook
around it stays as it was arranged. It starts hand-made, and a chapter put
in one of the other orders loses its lines and its handles exactly as the
notebook does: neither means anything against an order the app is choosing.

A line still would not go under another line, and the comparator was only
half of it. The write was then thrown away by the "did anything change?"
test, which sorted the separators before comparing them -- to ignore the
other chapters' lines riding along in the same field -- and a sorted
comparison cannot see a sequence. Two lines in one gap carry the same
position, so swapping them changes nothing else. It compares this list's own
lines, in order.

An open chapter swelled while rearranging: the strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty
space inside every chapter. They are thin now, and only there while
something is actually being carried -- which is also why the suite presses
first and aims afterwards.

Server: sort_mode on chapters, validated against the same five modes as a
notebook's, and set outside the COALESCE so null means the hand-made order
rather than "leave it alone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 16:45:05 +02:00
1 parent ea1bca5d25
commit 3b7958b713
11 files changed
+354 -94

No files matched your search

+29
View File
@@ -8,6 +8,35 @@ not from the output of `docker push`.
---
## 2026-08-17 — A chapter with an order of its own
**Images** — web `sha256:PENDING`, server `sha256:PENDING`
The server image changed: `sort_mode` on chapters.
**Added**
- **A chapter orders its own notes**, chosen in the same dialog as its name, mark
and colour — they are one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and filled,
so a reference chapter can stay alphabetical while the notebook around it stays
as it was arranged. It starts in the hand-made order, and a chapter put in one
of the others loses its lines and its handles exactly as the notebook does —
neither means anything against an order the app is choosing.
**Fixed**
- **A line still would not go under another line.** The comparator was only half
of it. The write was then thrown away by the "did anything change?" test, which
**sorted** the separators before comparing them — to ignore the other chapters'
lines riding along in the same field — and a sorted comparison cannot see a
sequence. Two lines in one gap carry the same position, so swapping them changes
nothing else. It compares this list's own lines, in order.
- **An open chapter swelled while rearranging.** The strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty space
inside every chapter — which read as though the notes in it had grown. They are
thin now, and only there while something is actually being carried.
---
## 2026-08-17 — A chapter is a list too, and deleting stops moving things
**Images** — web `sha256:c1bcdcc4…`, server `sha256:6c9af404…`
+38 -6
View File
@@ -474,6 +474,16 @@ if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
}
/* A chapter orders its own notes.
*
* Its own column rather than the notebook's setting, because a chapter is a
* place someone made and filled: a reference chapter can stay alphabetical
* while the notebook around it stays in the order it was arranged. Null means
* the hand-made order, which is what a chapter is in until it is told otherwise. */
if (!tableColumns("chapters").includes("sort_mode")) {
db.exec("ALTER TABLE chapters ADD COLUMN sort_mode TEXT");
}
if (!tableColumns("notes").includes("chapter_id")) {
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
@@ -1759,16 +1769,19 @@ const cleanIcon = (value) => {
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.prepare("SELECT id, notebook_id, name, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name, color, icon } = req.body || {};
const { notebookId, name, color, icon, sortMode } = req.body || {};
const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
// The notebook has to be this user's — otherwise a chapter could be hung off
// someone else's, and every note filed into it would follow.
@@ -1779,10 +1792,21 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
db.prepare(
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, sort_mode, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id,
req.userId,
notebookId,
label,
cleanColor(color),
cleanIcon(icon),
order,
sortMode ?? null,
req.deviceId
);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
res.json({ id, notebookId, name: label, sortOrder: order, sortMode: sortMode ?? null });
});
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
@@ -1804,9 +1828,12 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder, color, icon } = req.body || {};
const { name, sortOrder, color, icon, sortMode } = req.body || {};
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
let label;
if (name !== undefined) {
@@ -1826,6 +1853,11 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
if (icon !== undefined) {
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
// Also outside the COALESCE: null here means the hand-made order, not "leave
// it as it was".
if (sortMode !== undefined) {
db.prepare("UPDATE chapters SET sort_mode = ? WHERE id = ?").run(sortMode ?? null, req.params.id);
}
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
+80 -24
View File
@@ -282,25 +282,14 @@ try {
/* `where` picks the part of the row held over: a chapter is a place in the
body of its header and a neighbour along the bottom edge of it, which is
how a row can still be put in the gap under a chapter. */
const holdOver = (dragId, chapterId, where = "body", scope = "") =>
p.evaluate(
([from, to, part, sc]) => {
/* Both ends looked up in one list. The two ends of a list have strips
with the same names in every list on screen, so a bare id would find
the notebook's when the chapter's was meant. */
const find = (id) =>
document.querySelector(`[data-reorder-id="${id}"][data-reorder-scope="${sc}"]`);
const row = find(from);
const grip = row?.querySelector("[data-reorder-grip]");
const target = find(to);
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
window.__drop = {
x: box.x + box.width / 2,
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
};
const fire = (el, type, x, y) =>
/* Pressed first, aimed after.
Inside a chapter the strips at the two ends of the list only exist while
something is being carried — they are targets, not furniture — so the drop
point cannot be looked up until the row is in the hand. */
const holdOver = async (dragId, chapterId, where = "body", scope = "") => {
const pressed = await p.evaluate(
([from, sc]) => {
window.__fire = (el, type, x, y) =>
el.dispatchEvent(
new PointerEvent(type, {
pointerType: "touch",
@@ -311,14 +300,37 @@ try {
cancelable: true,
})
);
/* Looked up in one list: the two ends have the same names in every list
on screen, so a bare id would find the notebook's when the chapter's
was meant. */
const row = document.querySelector(`[data-reorder-id="${from}"][data-reorder-scope="${sc}"]`);
const grip = row?.querySelector("[data-reorder-grip]");
if (!grip) return "missing grip";
const s = grip.getBoundingClientRect();
fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
fire(window, "pointermove", window.__drop.x, window.__drop.y - 6);
fire(window, "pointermove", window.__drop.x, window.__drop.y);
window.__fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
return "pressed";
},
[dragId, scope]
);
if (pressed !== "pressed") return pressed;
await p.waitForTimeout(300);
return p.evaluate(
([to, part, sc]) => {
const target = document.querySelector(`[data-reorder-id="${to}"][data-reorder-scope="${sc}"]`);
if (!target) return "missing target";
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
window.__drop = {
x: box.x + box.width / 2,
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
};
window.__fire(window, "pointermove", window.__drop.x, window.__drop.y - 6);
window.__fire(window, "pointermove", window.__drop.x, window.__drop.y);
return "held";
},
[dragId, chapterId, where, scope]
[chapterId, where, scope]
);
};
const letGo = () =>
p.evaluate(() => {
@@ -490,6 +502,7 @@ try {
);
const beforeInside = await chapterOrder();
let afterInside = [];
if (beforeInside.length >= 2) {
const rows = await inChapterRows();
const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop();
@@ -497,7 +510,7 @@ try {
await p.waitForTimeout(400);
await letGo();
await p.waitForTimeout(2500);
const afterInside = await chapterOrder();
afterInside = await chapterOrder();
check(
afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length,
"and can be dragged among themselves, the strip at the top included",
@@ -519,6 +532,49 @@ try {
check(false, "a chapter with two notes to arrange", beforeInside.join(","));
}
/* ── a chapter orders its own notes ──
* The notebook's setting governs the notebook's own list. A chapter is a place
* someone made and filled, so it carries its own — set in the same dialog as
* its name, mark and colour, because they are one decision about one thing. */
await openRow.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
check((await p.locator("[data-chapter-sort]").count()) === 1, "its dialog offers an order of its own");
await p.locator("[data-chapter-sort]").selectOption("title");
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2500);
const stored = (await api(p, "GET", "/chapters")).body.find((c) => c.id === busiest);
check(stored?.sort_mode === "title", "and keeps it, on the server", JSON.stringify(stored?.sort_mode));
const alphabetical = await chapterOrder();
check(
alphabetical.join(",") === [...alphabetical].sort((a, b) => a.localeCompare(b, "de")).join(","),
"the chapter's notes come out in that order",
alphabetical.join(",")
);
/* And the things that only mean something in a hand-made order go with it: a
line the notes would slide past on every edit, and handles for an order the
app is choosing. */
check(
(await inChapterRows()).length === 0,
"with no handles in it, since that order is not dragged into place",
JSON.stringify(await inChapterRows())
);
await openRow.click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Edit", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator("[data-chapter-sort]").selectOption("manual");
await p.locator("[data-chapter-save]").click();
await p.waitForTimeout(2500);
check(
(await chapterOrder()).join(",") === afterInside.join(","),
"and the hand-made one is still there when it comes back",
`${afterInside.join(",")} vs ${(await chapterOrder()).join(",")}`
);
/* ── a dialog does not close because a selection ended outside it ──
* Select a word in the field, drag a little past the edge, let go: the click
* lands on the backdrop — the nearest ancestor of press and release — and the
+40
View File
@@ -303,6 +303,46 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* ── one line under another ──
* Two lines in the same gap carry the same position, so swapping them changes
* nothing but their sequence. The write was computed correctly and then thrown
* away by a "did anything change?" test that sorted before comparing — and a
* sorted comparison cannot see a sequence. The line sprang back every time. */
await p.locator("[data-add-separator]").click();
await p.waitForTimeout(1800);
const lines = () =>
p.locator("[data-separator]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
check((await p.locator("[data-separator]").count()) === 2, "a second line can be drawn");
// First bring them together, so what follows is the case that was broken:
// two lines in one gap, where only their sequence tells them apart.
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const lineOrder = await lines();
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
const swapped = await lines();
check(
swapped[0] === lineOrder[1] && swapped[1] === lineOrder[0],
"a line dropped on another in the same gap goes under it",
`${lineOrder.join(" | ")} → ${swapped.join(" | ")}`
);
// And it was written, not only drawn.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
(await lines()).join(" | ") === swapped.join(" | "),
"and stays under it after a reload",
(await lines()).join(" | ")
);
// Back to one line, so what follows reads the notebook it expects.
await p.locator("[data-separator]").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Remove separator").click();
await p.waitForTimeout(600);
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2000);
check((await p.locator("[data-separator]").count()) === 1, "and one of them can be taken away again");
/* ── a new note goes on top, and takes nothing with it ──
*
* It used to be given no position at all and left to the tie-break, which put
+29 -15
View File
@@ -368,12 +368,16 @@ export default function App() {
const isSearching = searchQuery.trim().length > 0;
/* The order a chapter's own notes are in — its own setting, chosen in its
dialog, and the hand-made one until it is given another. */
const chapterSort = openChapter?.sortMode || DEFAULT_NOTEBOOK_SORT;
/* Whether an arrangement is in effect, and so whether there is anything to
rearrange. A chapter always has one; the notebook's own list has one when it
is in the hand-made order. Searching suspends both — dragging against a
narrowed list would renumber notes it is hiding. */
const listReorderable = !!activeNotebook && !isSearching && (!!listChapter || sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter;
rearrange. Searching suspends it either way — dragging against a narrowed
list would renumber notes it is hiding. */
const listReorderable =
!!activeNotebook && !isSearching && (listChapter ? chapterSort === "manual" : sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter && chapterSort === "manual";
const listKey = `${activeFolder}:${listChapter ?? ""}`;
useEffect(() => {
setReordering(false);
@@ -390,12 +394,13 @@ export default function App() {
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
/* Inside a chapter the order is always the user's own.
/* A chapter carries its own order.
*
* The mode belongs to the notebook and governs the notebook's own list; a
* chapter is a place someone made and filled by hand, and filing a note into
* one only means something if it stays where it was put. */
const mode = listChapter ? "manual" : sortMode;
* The notebook's setting governs the notebook's own list; a chapter is a
* place someone made and filled, so it keeps its own — hand-made unless it
* was told otherwise, because filing a note into one only means something if
* it stays where it was put. */
const mode = listChapter ? chapterSort : sortMode;
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
@@ -417,13 +422,16 @@ export default function App() {
activeNotes,
trashedNotes,
sortMode,
chapterSort,
]);
/* The notes of the chapter being looked into, in the order they were put in.
Always the hand-made one, as in the list — see `filteredNotes`. */
/* The notes of the chapter being looked into, in the order it is set to. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), "manual") : []),
[activeChapter, activeNotes]
() =>
activeChapter
? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), chapterSort)
: [],
[activeChapter, activeNotes, chapterSort]
);
/* And the lines drawn inside it, which are its own — the notebook's stay in
the notebook, and each chapter's stay in that chapter. */
@@ -1227,7 +1235,13 @@ export default function App() {
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon)
: data.createChapter(
activeNotebook.id,
fields.name,
fields.color,
fields.icon,
fields.sortMode
)
}
onClose={() => setEditingChapter(null)}
dark={dark}
+10 -4
View File
@@ -242,14 +242,20 @@ export function ChapterNotes({
undefined,
`chapter:${chapterId ?? ""}`
);
// Something is in the hand, so the ends of the list need somewhere to drop it.
const carrying = !!moving && !!dnd.draggingId && entries.length > 0;
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{moving && entries.length > 0 && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 18 }}>
{/* Only while something is actually being carried, and thin.
Standing open for the whole of the mode, the two strips added half a
finger of empty space inside every open chapter — the block grew and
read as though the notes in it had swollen. */}
{carrying && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 12 }}>
{dnd.overId === START_ID && <DropLine T={T} />}
</div>
)}
@@ -327,8 +333,8 @@ export function ChapterNotes({
</div>
)
)}
{moving && entries.length > 0 && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}>
{carrying && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 16 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
)}
+31 -20
View File
@@ -13,6 +13,7 @@ import type {
NoteMeta,
Notebook,
Separator,
SortMode,
StrokeDoc,
User,
} from "../types";
@@ -68,10 +69,14 @@ export interface NoteData {
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
icon?: string | null,
sortMode?: SortMode | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
/** Everything a chapter is, in one write — it is decided in one dialog. */
editChapter: (
id: string,
updates: { name?: string; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
@@ -283,7 +288,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const all = notebook.separators || [];
return {
notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
/* Chapters are rows in this list too, and only in the notebook itself — a
@@ -297,7 +301,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
(
notebookId: string,
chapterId: string | null,
all: Separator[],
/** This list's own lines as they were, for telling whether anything moved. */
mine: Separator[],
others: Separator[],
next: Arrangement
) => {
@@ -320,15 +325,15 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
* Compared **in order**, and only this list's own lines. Sorting the
* comparison was how a line refused to go under another one: two lines in
* the same gap carry the same position, so swapping them changes nothing
* but their sequence — and a sorted comparison cannot see a sequence. The
* move was computed correctly, drawn correctly for a frame, and then
* thrown away as "no change". */
const fingerprint = (seps: Separator[]) =>
seps
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
seps.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.color ?? ""}|${sep.style ?? ""}`).join("\n");
if (fingerprint(next.separators) !== fingerprint(mine)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
@@ -348,7 +353,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next);
applyArrangement(notebookId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -372,7 +377,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -394,7 +399,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -479,8 +484,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
async (
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null,
sortMode: SortMode | null = null
) => {
const chapter = await sync.createChapter(notebookId, name, color, icon, sortMode);
setChapters((p) => [...p, chapter]);
return chapter;
},
@@ -492,7 +503,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* They used to be two calls because they came from two places — a dialog and
* a submenu — and renaming a chapter while recolouring it then meant two
* requests for one decision. */
const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => {
const editChapter = useCallback(async (id: string, updates: Parameters<NoteData["editChapter"]>[1]) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
await sync.updateChapter(id, updates);
}, []);
@@ -528,7 +539,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
)
: // An empty one only leaves a gap to close.
withoutRow(list.notes, list.mine, list.chapters, id);
if (next) applyArrangement(notebookId, null, list.all, list.others, next);
if (next) applyArrangement(notebookId, null, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
+32 -8
View File
@@ -16,6 +16,7 @@ import type {
NoteVersionSummary,
Notebook,
QueueEntry,
SortMode,
StrokeDoc,
SyncStatus,
User,
@@ -86,6 +87,7 @@ interface ServerChapter {
color?: string | null;
icon?: string | null;
sort_order?: number;
sort_mode?: string | null;
}
interface ServerNote {
@@ -547,11 +549,25 @@ type QueuedAction =
| { action: "deleteNotebook"; data: { id: string } }
| {
action: "createChapter";
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
data: {
localId: string;
notebookId: string;
name: string;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
data: {
id: string;
name?: string;
sortOrder?: number;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| { action: "deleteChapter"; data: { id: string } }
| {
@@ -667,6 +683,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
sortMode: d.sortMode ?? null,
});
if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
@@ -678,6 +695,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
color: d.color ?? null,
icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0,
sortMode: d.sortMode ?? null,
});
// Notes filed into it while it was local, and anything still queued
// behind this create — both address a name only this device knew.
@@ -694,6 +712,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
sortOrder: a.data.sortOrder,
color: a.data.color,
icon: a.data.icon,
sortMode: a.data.sortMode,
});
break;
case "deleteChapter":
@@ -829,6 +848,10 @@ export async function fetchChapters(): Promise<Chapter[]> {
color: c.color ?? null,
icon: c.icon ?? null,
sortOrder: c.sort_order || 0,
/* Read here or it is lost: every sync replaces the local mirror with what
this function returns, and a chapter that came back without its order
would drop to the hand-made one seconds after being set. */
sortMode: (c.sort_mode as SortMode | null) ?? null,
}));
const local = await db.getAllChapters();
// Anything made offline is kept: its create is still in the queue, and the
@@ -843,13 +866,14 @@ export async function createChapter(
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null
icon: string | null = null,
sortMode: SortMode | null = null
): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon, sortMode });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder, sortMode };
await db.putChapter(chapter);
notifyData("chapter", chapter);
return chapter;
@@ -858,9 +882,9 @@ export async function createChapter(
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, sortMode, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
@@ -868,7 +892,7 @@ export async function createChapter(
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null }
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
+4
View File
@@ -126,6 +126,10 @@ export interface Chapter {
/** An emoji standing in for the default folder mark. */
icon?: string | null;
sortOrder: number;
/* How its own notes are ordered — the notebook's setting governs the
notebook's own list, not what is inside a chapter. Null is the hand-made
order, which is what a chapter is in until it is told otherwise. */
sortMode?: SortMode | null;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
}
+34 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { SORT_LABELS, SORT_MODES } from "../features/notes/noteSort";
import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
@@ -23,6 +24,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null);
const [sortMode, setSortMode] = useState(chapter?.sortMode || "manual");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
@@ -46,7 +48,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour });
onSave({ name: name.trim(), icon, color: colour, sortMode });
onClose();
};
@@ -144,6 +146,37 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
}}
/>
{/* A chapter orders its own notes.
The notebook's setting governs the notebook's own list; a chapter is
a place someone made and filled, so a reference chapter can stay
alphabetical while the notebook around it stays as it was arranged.
Lines and handles belong to the hand-made order and are only offered
there — in "newest first" the notes would slide past a line. */}
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Order</div>
<select
value={sortMode}
aria-label="Chapter order"
data-chapter-sort
onChange={(e) => setSortMode(e.target.value)}
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
fontFamily: "inherit",
outline: "none",
}}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{SORT_LABELS[mode]}
</option>
))}
</select>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
+27 -16
View File
@@ -334,11 +334,15 @@ handlers — a finger generates no `pointerover` on anything it did not press.
**Two lines in the same gap keep the order they were written in.** A separator's
position counts the rows above it, so two lines with nothing between them carry
the same number, and only the order they are stored in can tell them apart. The
comparator used to say "a separator comes first" without asking what the other
one was — claiming both directions at once for that case. A comparator that
contradicts itself has no defined result, and a line dragged under another sprang
back above it every time.
the same number, and only the order they are stored in can tell them apart. That
took two fixes for one symptom. The comparator used to say "a separator comes
first" without asking what the other one was — claiming both directions at once
for that case, and a comparator that contradicts itself has no defined result.
And the write was then thrown away: the "did anything change?" test **sorted**
the separators before comparing them, to ignore the other chapters' lines riding
along in the same field — and a sorted comparison cannot see a sequence. The move
was computed correctly, drawn for a frame, and discarded. It compares this list's
own lines, in order.
**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
@@ -462,8 +466,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 64 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 51 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging |
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
| `chapters` | 60 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `tables` | 8 | a long table is broken between its rows, with none left lying across the gap, and no page furniture reaches the stored note |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
@@ -1113,15 +1117,22 @@ still reads newest-first — untouched rows all carry position 0 and fall back t
it — so nothing is lost by starting there, while starting in "last changed" made
both separators and dragging look broken until the sort menu was found.
**Inside a chapter the order is always the user's own.** The mode belongs to the
notebook and governs the notebook's own list; a chapter is a place someone made
and filled by hand, and filing a note into one only means something if it stays
where it was put. So a chapter has no sort menu of its own, its lines always
show, and its rows can always be dragged — with the *same* switch as the list
beside it, because two switches for two lists looked at together is one too many.
The two lists keep out of each other's way 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.
**A chapter orders its own notes.** The notebook's setting governs the
notebook's own list; a chapter is a place someone made and filled, so it carries
a mode of its own — set in the same dialog as its name, mark and colour, because
they are one decision about one thing. It starts in the hand-made order, which is
what filing a note into a chapter means: it stays where it was put. A chapter in
one of the other orders loses its lines and its handles, exactly as the notebook
does, because neither means anything against an order the app is choosing.
While it *is* in the hand-made order, its rows are dragged with the **same
switch** as the list beside it — two switches for two lists looked at together is
one too many. The two lists keep out of each other's way 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 two
strips at the ends of a chapter's list appear only while something is being
carried; standing open for the whole of the mode they added half a finger of
empty space to every open chapter.
**A row leaving is the same problem as a row arriving, in reverse.** Deleting a
note above a line left the line counting rows that were no longer there, so