Compare commits

..
2 Commits
Author SHA1 Message Date
marcandClaude Opus 5 1c0f2184e6 Record the digests of the images that went out
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:45:55 +02:00
marcandClaude Opus 5 3b7958b713 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>
2026-08-17 16:45:05 +02:00
11 changed files with 354 additions and 94 deletions

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:b7c9f8b7…`, server `sha256:a0fd140a…`
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 ## 2026-08-17 — A chapter is a list too, and deleting stops moving things
**Images** — web `sha256:c1bcdcc4…`, server `sha256:6c9af404…` **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"); 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")) { 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("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)"); 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) => { app.get("/api/chapters", authMiddleware, (req, res) => {
res.json( res.json(
db 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) .all(req.userId)
); );
}); });
app.post("/api/chapters", authMiddleware, (req, res) => { 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(); const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" }); 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 (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 // 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. // 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 = ?") .prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n; .get(notebookId).n;
const id = uuid(); const id = uuid();
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") db.prepare(
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId); "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); 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) => { 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) => { 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); 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 (!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; let label;
if (name !== undefined) { if (name !== undefined) {
@@ -1826,6 +1853,11 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
if (icon !== undefined) { if (icon !== undefined) {
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id); 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); broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true }); 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 /* `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 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. */ how a row can still be put in the gap under a chapter. */
const holdOver = (dragId, chapterId, where = "body", scope = "") => /* Pressed first, aimed after.
p.evaluate( Inside a chapter the strips at the two ends of the list only exist while
([from, to, part, sc]) => { something is being carried — they are targets, not furniture — so the drop
/* Both ends looked up in one list. The two ends of a list have strips point cannot be looked up until the row is in the hand. */
with the same names in every list on screen, so a bare id would find const holdOver = async (dragId, chapterId, where = "body", scope = "") => {
the notebook's when the chapter's was meant. */ const pressed = await p.evaluate(
const find = (id) => ([from, sc]) => {
document.querySelector(`[data-reorder-id="${id}"][data-reorder-scope="${sc}"]`); window.__fire = (el, type, x, y) =>
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) =>
el.dispatchEvent( el.dispatchEvent(
new PointerEvent(type, { new PointerEvent(type, {
pointerType: "touch", pointerType: "touch",
@@ -311,14 +300,37 @@ try {
cancelable: true, 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(); const s = grip.getBoundingClientRect();
fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2); window.__fire(grip, "pointerdown", s.x + s.width / 2, s.y + s.height / 2);
fire(window, "pointermove", window.__drop.x, window.__drop.y - 6); return "pressed";
fire(window, "pointermove", window.__drop.x, window.__drop.y); },
[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"; return "held";
}, },
[dragId, chapterId, where, scope] [chapterId, where, scope]
); );
};
const letGo = () => const letGo = () =>
p.evaluate(() => { p.evaluate(() => {
@@ -490,6 +502,7 @@ try {
); );
const beforeInside = await chapterOrder(); const beforeInside = await chapterOrder();
let afterInside = [];
if (beforeInside.length >= 2) { if (beforeInside.length >= 2) {
const rows = await inChapterRows(); const rows = await inChapterRows();
const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop(); const last = rows.filter((id) => id !== "__start__" && id !== "__end__").pop();
@@ -497,7 +510,7 @@ try {
await p.waitForTimeout(400); await p.waitForTimeout(400);
await letGo(); await letGo();
await p.waitForTimeout(2500); await p.waitForTimeout(2500);
const afterInside = await chapterOrder(); afterInside = await chapterOrder();
check( check(
afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length, afterInside[0] === beforeInside[beforeInside.length - 1] && afterInside.length === beforeInside.length,
"and can be dragged among themselves, the strip at the top included", "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(",")); 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 ── /* ── 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 * 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 * 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); await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in"); 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 ── /* ── 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 * 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; 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 /* 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 rearrange. Searching suspends it either way — dragging against a narrowed
is in the hand-made order. Searching suspends both — dragging against a list would renumber notes it is hiding. */
narrowed list would renumber notes it is hiding. */ const listReorderable =
const listReorderable = !!activeNotebook && !isSearching && (!!listChapter || sortMode === "manual"); !!activeNotebook && !isSearching && (listChapter ? chapterSort === "manual" : sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter; const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter && chapterSort === "manual";
const listKey = `${activeFolder}:${listChapter ?? ""}`; const listKey = `${activeFolder}:${listChapter ?? ""}`;
useEffect(() => { useEffect(() => {
setReordering(false); setReordering(false);
@@ -390,12 +394,13 @@ export default function App() {
look like it had done nothing. */ look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId); 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 * The notebook's setting governs the notebook's own list; a chapter is a
* chapter is a place someone made and filled by hand, and filing a note into * place someone made and filled, so it keeps its own — hand-made unless it
* one only means something if it stays where it was put. */ * was told otherwise, because filing a note into one only means something if
const mode = listChapter ? "manual" : sortMode; * it stays where it was put. */
const mode = listChapter ? chapterSort : sortMode;
if (searchQuery.trim()) { if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance // A search reads every note's text, so results keep the index's relevance
@@ -417,13 +422,16 @@ export default function App() {
activeNotes, activeNotes,
trashedNotes, trashedNotes,
sortMode, sortMode,
chapterSort,
]); ]);
/* The notes of the chapter being looked into, in the order they were put in. /* The notes of the chapter being looked into, in the order it is set to. */
Always the hand-made one, as in the list — see `filteredNotes`. */
const chapterNotes = useMemo( 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 /* 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. */ the notebook, and each chapter's stay in that chapter. */
@@ -1227,7 +1235,13 @@ export default function App() {
onSave={(fields) => onSave={(fields) =>
editingChapter.id editingChapter.id
? data.editChapter(editingChapter.id, fields) ? 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)} onClose={() => setEditingChapter(null)}
dark={dark} dark={dark}
+10 -4
View File
@@ -242,14 +242,20 @@ export function ChapterNotes({
undefined, undefined,
`chapter:${chapterId ?? ""}` `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 ( return (
<> <>
{entries.length === 0 && ( {entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div> <div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)} )}
{moving && entries.length > 0 && ( {/* Only while something is actually being carried, and thin.
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 18 }}> 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} />} {dnd.overId === START_ID && <DropLine T={T} />}
</div> </div>
)} )}
@@ -327,8 +333,8 @@ export function ChapterNotes({
</div> </div>
) )
)} )}
{moving && entries.length > 0 && ( {carrying && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}> <div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 16 }}>
{dnd.overId === END_ID && <DropLine T={T} />} {dnd.overId === END_ID && <DropLine T={T} />}
</div> </div>
)} )}
+31 -20
View File
@@ -13,6 +13,7 @@ import type {
NoteMeta, NoteMeta,
Notebook, Notebook,
Separator, Separator,
SortMode,
StrokeDoc, StrokeDoc,
User, User,
} from "../types"; } from "../types";
@@ -68,10 +69,14 @@ export interface NoteData {
notebookId: string, notebookId: string,
name: string, name: string,
color?: string | null, color?: string | null,
icon?: string | null icon?: string | null,
sortMode?: SortMode | null
) => Promise<Chapter>; ) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */ /** Everything a chapter is, in one write — it is decided in one dialog. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>; editChapter: (
id: string,
updates: { name?: string; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
) => Promise<void>;
deleteChapter: (id: string) => Promise<void>; deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>; deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => 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 || []; const all = notebook.separators || [];
return { return {
notes, notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId), mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: 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 /* 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, notebookId: string,
chapterId: string | null, chapterId: string | null,
all: Separator[], /** This list's own lines as they were, for telling whether anything moved. */
mine: Separator[],
others: Separator[], others: Separator[],
next: Arrangement 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 * that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag. * a different order after every drag.
* *
* Compared as a set rather than as an array, because the merge above puts * Compared **in order**, and only this list's own lines. Sorting the
* the other chapters' separators first and that says nothing about whether * comparison was how a line refused to go under another one: two lines in
* anything changed. */ * 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[]) => const fingerprint = (seps: Separator[]) =>
seps seps.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.color ?? ""}|${sep.style ?? ""}`).join("\n");
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`) if (fingerprint(next.separators) !== fingerprint(mine)) {
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb))); setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged }); void sync.updateNotebook(notebookId, { separators: merged });
} }
@@ -348,7 +353,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return; if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters); const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return; if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next); applyArrangement(notebookId, chapterId, list.mine, list.others, next);
}, },
[applyArrangement, listOf] [applyArrangement, listOf]
); );
@@ -372,7 +377,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return; if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id); const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return; if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next); applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
}, },
[applyArrangement, listOf] [applyArrangement, listOf]
); );
@@ -394,7 +399,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return; if (!list) return;
const next = withoutRow(list.notes, list.mine, list.chapters, note.id); const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
if (!next) return; if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next); applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
}, },
[applyArrangement, listOf] [applyArrangement, listOf]
); );
@@ -479,8 +484,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/ */
const createChapter = useCallback( const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => { async (
const chapter = await sync.createChapter(notebookId, name, color, icon); 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]); setChapters((p) => [...p, chapter]);
return 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 * 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 * a submenu — and renaming a chapter while recolouring it then meant two
* requests for one decision. */ * 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))); setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
await sync.updateChapter(id, updates); 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. : // An empty one only leaves a gap to close.
withoutRow(list.notes, list.mine, list.chapters, id); 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] [applyArrangement, listOf]
); );
+32 -8
View File
@@ -16,6 +16,7 @@ import type {
NoteVersionSummary, NoteVersionSummary,
Notebook, Notebook,
QueueEntry, QueueEntry,
SortMode,
StrokeDoc, StrokeDoc,
SyncStatus, SyncStatus,
User, User,
@@ -86,6 +87,7 @@ interface ServerChapter {
color?: string | null; color?: string | null;
icon?: string | null; icon?: string | null;
sort_order?: number; sort_order?: number;
sort_mode?: string | null;
} }
interface ServerNote { interface ServerNote {
@@ -547,11 +549,25 @@ type QueuedAction =
| { action: "deleteNotebook"; data: { id: string } } | { action: "deleteNotebook"; data: { id: string } }
| { | {
action: "createChapter"; 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"; 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 } } | { action: "deleteChapter"; data: { id: string } }
| { | {
@@ -667,6 +683,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: d.name, name: d.name,
color: d.color ?? null, color: d.color ?? null,
icon: d.icon ?? null, icon: d.icon ?? null,
sortMode: d.sortMode ?? null,
}); });
if (r.id !== d.localId) { if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.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, color: d.color ?? null,
icon: d.icon ?? null, icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0, sortOrder: local?.sortOrder ?? 0,
sortMode: d.sortMode ?? null,
}); });
// Notes filed into it while it was local, and anything still queued // Notes filed into it while it was local, and anything still queued
// behind this create — both address a name only this device knew. // 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, sortOrder: a.data.sortOrder,
color: a.data.color, color: a.data.color,
icon: a.data.icon, icon: a.data.icon,
sortMode: a.data.sortMode,
}); });
break; break;
case "deleteChapter": case "deleteChapter":
@@ -829,6 +848,10 @@ export async function fetchChapters(): Promise<Chapter[]> {
color: c.color ?? null, color: c.color ?? null,
icon: c.icon ?? null, icon: c.icon ?? null,
sortOrder: c.sort_order || 0, 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(); const local = await db.getAllChapters();
// Anything made offline is kept: its create is still in the queue, and the // Anything made offline is kept: its create is still in the queue, and the
@@ -843,13 +866,14 @@ export async function createChapter(
notebookId: string, notebookId: string,
name: string, name: string,
color: string | null = null, color: string | null = null,
icon: string | null = null icon: string | null = null,
sortMode: SortMode | null = null
): Promise<Chapter> { ): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length; const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) { if (_online && api.token) {
try { try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon }); const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon, sortMode });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder }; const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder, sortMode };
await db.putChapter(chapter); await db.putChapter(chapter);
notifyData("chapter", chapter); notifyData("chapter", chapter);
return chapter; return chapter;
@@ -858,9 +882,9 @@ export async function createChapter(
} }
} }
const lid = "ch_" + Math.random().toString(36).slice(2, 10); 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 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); notifyData("chapter", chapter);
notifyStatus("pending"); notifyStatus("pending");
return chapter; return chapter;
@@ -868,7 +892,7 @@ export async function createChapter(
export async function updateChapter( export async function updateChapter(
id: string, 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> { ): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id); const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return; if (!chapter) return;
+4
View File
@@ -126,6 +126,10 @@ export interface Chapter {
/** An emoji standing in for the default folder mark. */ /** An emoji standing in for the default folder mark. */
icon?: string | null; icon?: string | null;
sortOrder: number; 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. */ /** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean; _pendingCreate?: boolean;
} }
+34 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx"; import { CHAPTER_COLORS } from "./constants.jsx";
import { SORT_LABELS, SORT_MODES } from "../features/notes/noteSort";
import { IconChooser } from "./IconChooser.jsx"; import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx"; import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop"; import { useBackdropClose } from "./backdrop";
@@ -23,6 +24,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const [name, setName] = useState(chapter?.name || ""); const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null); const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null); const [colour, setColour] = useState(chapter?.color ?? null);
const [sortMode, setSortMode] = useState(chapter?.sortMode || "manual");
const inputRef = useRef(null); const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose); const backdrop = useBackdropClose(onClose);
@@ -46,7 +48,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const ready = !!name.trim(); const ready = !!name.trim();
const save = () => { const save = () => {
if (!ready) return; if (!ready) return;
onSave({ name: name.trim(), icon, color: colour }); onSave({ name: name.trim(), icon, color: colour, sortMode });
onClose(); 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={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}> <div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => { {[{ 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 **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 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 the same number, and only the order they are stored in can tell them apart. That
comparator used to say "a separator comes first" without asking what the other took two fixes for one symptom. The comparator used to say "a separator comes
one was — claiming both directions at once for that case. A comparator that first" without asking what the other one was — claiming both directions at once
contradicts itself has no defined result, and a line dragged under another sprang for that case, and a comparator that contradicts itself has no defined result.
back above it every time. 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.** **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 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 | | `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 | | `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 | | `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 | | `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` | 51 | a filed note stays filed across a sync; the notebook's separators survive a chapter being opened; handles appear only while rearranging | | `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 | | `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 | | `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 | | `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 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. 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 **A chapter orders its own notes.** The notebook's setting governs the
notebook and governs the notebook's own list; a chapter is a place someone made notebook's own list; a chapter is a place someone made and filled, so it carries
and filled by hand, and filing a note into one only means something if it stays a mode of its own — set in the same dialog as its name, mark and colour, because
where it was put. So a chapter has no sort menu of its own, its lines always they are one decision about one thing. It starts in the hand-made order, which is
show, and its rows can always be dragged — with the *same* switch as the list what filing a note into a chapter means: it stays where it was put. A chapter in
beside it, because two switches for two lists looked at together is one too many. one of the other orders loses its lines and its handles, exactly as the notebook
The two lists keep out of each other's way by scope: a chapter unfolded under its does, because neither means anything against an order the app is choosing.
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. 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 **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 note above a line left the line counting rows that were no longer there, so