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 && (
Nothing in here yet.
)} - {moving && entries.length > 0 && ( -
+ {/* 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 && ( +
{dnd.overId === START_ID && }
)} @@ -327,8 +333,8 @@ export function ChapterNotes({
) )} - {moving && entries.length > 0 && ( -
+ {carrying && ( +
{dnd.overId === END_ID && }
)} diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 321015c..28d9838 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -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; - /** Name, colour, or both — one write for one decision. */ - editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise; + /** 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; deleteChapter: (id: string) => Promise; deleteNotebook: (id: string) => Promise; 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[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] ); diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index 9fa3c93..8974d32 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -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 { 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 { 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 { 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 { 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 { 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 { const chapter = (await db.getAllChapters()).find((c) => c.id === id); if (!chapter) return; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ffe0ec1..2809e34 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; } diff --git a/frontend/src/ui/ChapterDialog.jsx b/frontend/src/ui/ChapterDialog.jsx index 783d9eb..8fee89b 100644 --- a/frontend/src/ui/ChapterDialog.jsx +++ b/frontend/src/ui/ChapterDialog.jsx @@ -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. */} +
Order
+ +
Colour
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => { diff --git a/readme.md b/readme.md index e307275..b2a8594 100644 --- a/readme.md +++ b/readme.md @@ -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