diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c07e75..16d0a82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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…` diff --git a/backend/server.js b/backend/server.js index 520e4ef..eb2b082 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 }); }); diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs index 78ad29f..b220d9d 100644 --- a/e2e/chapters.mjs +++ b/e2e/chapters.mjs @@ -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 diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 2f7368c..7c070f4 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d34a57d..f8c2181 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index b779b1d..4721953 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -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 && (