A chapter is a list too, and deleting stops moving things

Deleting a note moved everything else. A row leaving is the same problem as
a row arriving, in reverse: a line's position counts the rows above it, so a
note deleted above a line left the line counting rows that were no longer
there, and everything under it appeared to jump up past it. withoutRow
renumbers the list a row leaves, and every place one leaves says so -- the
bin, a move, a chapter being removed. Arrivals land at the top: an old
position belongs to an arrangement that has moved on.

A line could not be dragged under another line. Two lines in the same gap
carry the same position, and the comparator claimed "a separator comes
first" without asking what the other one was -- both directions at once for
that case. A comparator that contradicts itself has no defined result, so
the sort returned them in whichever order it happened to walk.

A chapter is always in the user's own order and can be arranged like any
other list: its notes wear handles under the same switch as the list beside
them and are dragged among themselves, strips at both ends included. The two
lists keep apart 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 sort menu is gone inside a
chapter: the mode belongs to the notebook, and a menu there would offer a
choice that cannot be honoured.

The chapters suite no longer assumes the first chapter row is the one that
matters. A chapter is a row in the arrangement and can be anywhere in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 12:37:02 +02:00
1 parent 312c4e190e
commit f01522aa6f
10 files changed
+528 -85

No files matched your search

+107 -19
View File
@@ -282,12 +282,17 @@ 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") =>
const holdOver = (dragId, chapterId, where = "body", scope = "") =>
p.evaluate(
([from, to, part]) => {
const row = document.querySelector(`[data-reorder-id="${from}"]`);
([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 = document.querySelector(`[data-reorder-id="${to}"]`);
const target = find(to);
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
@@ -312,7 +317,7 @@ try {
fire(window, "pointermove", window.__drop.x, window.__drop.y);
return "held";
},
[dragId, chapterId, where]
[dragId, chapterId, where, scope]
);
const letGo = () =>
@@ -359,8 +364,17 @@ try {
const looseBefore = (await titles()).length;
/* Both ends of the drag are read off the rows on screen: what is draggable is
what is rendered, and the server's idea of the notebook includes rows this
list is not showing. */
const rowOrder = await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId));
list is not showing.
Only the notebook's own rows: an open chapter draws its notes with handles
of their own, and those belong to a second list — dragging one of those from
here would be measuring one list against the other. */
const notebookRows = () =>
p
.locator("[data-reorder-id]")
.evaluateAll((els) =>
els.filter((e) => (e.dataset.reorderScope ?? "") === "").map((e) => e.dataset.reorderId)
);
const rowOrder = await notebookRows();
const shownChapters = await p
.locator("[data-chapter-row]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
@@ -391,9 +405,7 @@ try {
/* A line is drawn in the notebook and dragged in, which is how a chapter is
divided at all. */
const sepRow = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find((id) => id && id.startsWith("sep-"));
const sepRow = (await notebookRows()).find((id) => id && id.startsWith("sep-"));
if (sepRow) {
await dragOnto(sepRow, shownChapters[0]);
await p.waitForTimeout(2500);
@@ -414,9 +426,7 @@ try {
* not be moved among its neighbours, and nothing could be put in the gap under
* one. The bottom edge of the header is that gap, and asks for a line rather
* than lighting the chapter up. */
const looseLeft = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find(
const looseLeft = (await notebookRows()).find(
(id) =>
id &&
!id.startsWith("sep-") &&
@@ -440,6 +450,75 @@ try {
check(false, "a loose note was left to try the edge with", "none");
}
/* ── a chapter is a list of its own, and can be arranged like one ──
* Its notes are shown with handles under the same switch as the list beside
* them, and dragged among themselves. They could not be dragged at all before:
* a chapter was somewhere to put notes and not somewhere to arrange them. */
/* The one that actually holds notes, asked of the server rather than assumed
to be the first row: which chapter comes first is an arrangement, and this
test is not about that. */
const fullest = (await api(p, "GET", "/notes")).body
.filter((n) => n.chapter_id)
.reduce((counts, n) => counts.set(n.chapter_id, (counts.get(n.chapter_id) || 0) + 1), new Map());
const busiest = [...fullest.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
const openRow = p.locator(`[data-chapter-row="${busiest}"]`);
if ((await p.locator(`[data-chapter-notes="${busiest}"]`).count()) === 0) {
await openRow.click();
await p.waitForTimeout(1600);
}
if ((await p.locator("[data-reorder-toggle]").getAttribute("data-reorder-toggle")) === "off") {
await p.locator("[data-reorder-toggle]").click();
await p.waitForTimeout(700);
}
const inChapterRows = () =>
p
.locator("[data-reorder-id]")
.evaluateAll((els) =>
els
.filter((e) => (e.dataset.reorderScope ?? "").startsWith("chapter:"))
.map((e) => e.dataset.reorderId)
);
const chapterOrder = () =>
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
const handles = await inChapterRows();
check(handles.length > 0, "the notes inside a chapter wear handles too", JSON.stringify(handles));
// The name of the chapter's list, which both ends of the drag are looked up in.
const chapterScope = await p
.locator("[data-reorder-id]")
.evaluateAll(
(els) => els.map((e) => e.dataset.reorderScope ?? "").find((s) => s.startsWith("chapter:")) ?? ""
);
const beforeInside = await chapterOrder();
if (beforeInside.length >= 2) {
const rows = await inChapterRows();
const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop();
await holdOver(last, "__start__", "body", chapterScope);
await p.waitForTimeout(400);
await letGo();
await p.waitForTimeout(2500);
const 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",
`${beforeInside.join(",")} → ${afterInside.join(",")}`
);
// Written, not only drawn: the chapter's order is the notebook's business
// like any other, and has to survive the trip.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
await openNotebook();
await openRow.click();
await p.waitForTimeout(1800);
check(
(await chapterOrder()).join(",") === afterInside.join(","),
"and the chapter comes back in the order it was left in",
(await chapterOrder()).join(",")
);
} else {
check(false, "a chapter with two notes to arrange", beforeInside.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
@@ -537,8 +616,17 @@ try {
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
* notebook, where they can be seen and filed again.
*
* The one holding notes, named rather than taken as "the first row": a chapter
* is a row in the arrangement like any other and can be anywhere in it. */
const holdings = (await api(p, "GET", "/notes")).body.filter((n) => n.chapter_id);
const doomed = holdings[0]?.chapter_id;
const freedTitles = holdings
.filter((n) => n.chapter_id === doomed)
.map((n) => n.title)
.sort();
await p.locator(`[data-chapter-row="${doomed}"]`).click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.getByText("Remove chapter", { exact: true }).click();
await p.waitForTimeout(700);
@@ -562,15 +650,15 @@ try {
"the chapter is gone, and only that one",
String(await p.locator("[data-chapter-row]").count())
);
const after = (await titles()).sort().join(",");
const after = (await titles()).sort();
check(
after === "Gartenzaun,Stromrechnung,Wasserrechnung,Zählerstand",
freedTitles.every((t) => after.includes(t)),
"and every note it held is back in the notebook",
after
`${freedTitles.join(",")} → ${after.join(",")}`
);
const freed = (await api(p, "GET", "/notes")).body;
check(
freed.every((n) => !n.chapter_id),
freed.every((n) => n.chapter_id !== doomed),
"with nothing on the server still pointing at a chapter that is gone",
JSON.stringify(freed.map((n) => [n.title, n.chapter_id]))
);
+20 -1
View File
@@ -344,6 +344,25 @@ try {
(await layout()).join(" | ")
);
/* ── and taking one away is the same promise, in reverse ──
* A line's position counts the rows above it, so deleting a note above one
* left the line counting rows that were no longer there: everything under it
* appeared to jump up past the line, which is what made deleting feel as
* though it scrambled the notebook. */
const beforeDelete = await layout();
await p.locator("[data-note-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-context-menu]").getByText("Move to Trash", { exact: true }).click();
await p.waitForTimeout(700);
await p.locator("[data-confirm-dialog]").getByRole("button", { name: "Move to Trash", exact: true }).click();
await p.waitForTimeout(2500);
const arrangedRows = await layout();
check(
arrangedRows.join(" | ") === beforeDelete.slice(1).join(" | "),
"deleting a note takes out only that note — the lines keep the rows they were drawn above",
`${beforeDelete.join(" | ")} → ${arrangedRows.join(" | ")}`
);
/* ── the choices reach the server, and the screen ──
* A notebook divided into three parts is easier to read at a glance when the
* lines differ; grey-on-grey they all look like the same line repeated. */
@@ -390,7 +409,7 @@ try {
"and the question is gone with it, not left standing over the app"
);
check(
(await titles()).join(",") === afterNew.filter((row) => !row.startsWith("──")).join(","),
(await titles()).join(",") === arrangedRows.filter((row) => !row.startsWith("──")).join(","),
"with every note still there",
(await titles()).join(", ")
);