A chapter with an order of its own
A chapter orders its own notes now, chosen in the same dialog as its name, mark and colour -- one decision about one thing. The notebook's setting governs the notebook's own list; a chapter is a place someone made and filled, so a reference chapter can stay alphabetical while the notebook around it stays as it was arranged. It starts hand-made, and a chapter put in one of the other orders loses its lines and its handles exactly as the notebook does: neither means anything against an order the app is choosing. A line still would not go under another line, and the comparator was only half of it. The write was then thrown away by the "did anything change?" test, which sorted the separators before comparing them -- to ignore the other chapters' lines riding along in the same field -- and a sorted comparison cannot see a sequence. Two lines in one gap carry the same position, so swapping them changes nothing else. It compares this list's own lines, in order. An open chapter swelled while rearranging: the strips at the two ends of its list stood open for the whole of the mode, adding half a finger of empty space inside every chapter. They are thin now, and only there while something is actually being carried -- which is also why the suite presses first and aims afterwards. Server: sort_mode on chapters, validated against the same five modes as a notebook's, and set outside the COALESCE so null means the hand-made order rather than "leave it alone". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea1bca5d25
commit
3b7958b713
11 files changed
+354
-94
No files matched your search
@@ -8,6 +8,35 @@ not from the output of `docker push`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-17 — A chapter with an order of its own
|
||||
|
||||
**Images** — web `sha256:PENDING`, server `sha256:PENDING`
|
||||
|
||||
The server image changed: `sort_mode` on chapters.
|
||||
|
||||
**Added**
|
||||
- **A chapter orders its own notes**, chosen in the same dialog as its name, mark
|
||||
and colour — they are one decision about one thing. The notebook's setting
|
||||
governs the notebook's own list; a chapter is a place someone made and filled,
|
||||
so a reference chapter can stay alphabetical while the notebook around it stays
|
||||
as it was arranged. It starts in the hand-made order, and a chapter put in one
|
||||
of the others loses its lines and its handles exactly as the notebook does —
|
||||
neither means anything against an order the app is choosing.
|
||||
|
||||
**Fixed**
|
||||
- **A line still would not go under another line.** The comparator was only half
|
||||
of it. The write was then thrown away by the "did anything change?" test, which
|
||||
**sorted** the separators before comparing them — to ignore the other chapters'
|
||||
lines riding along in the same field — and a sorted comparison cannot see a
|
||||
sequence. Two lines in one gap carry the same position, so swapping them changes
|
||||
nothing else. It compares this list's own lines, in order.
|
||||
- **An open chapter swelled while rearranging.** The strips at the two ends of its
|
||||
list stood open for the whole of the mode, adding half a finger of empty space
|
||||
inside every chapter — which read as though the notes in it had grown. They are
|
||||
thin now, and only there while something is actually being carried.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-17 — A chapter is a list too, and deleting stops moving things
|
||||
|
||||
**Images** — web `sha256:c1bcdcc4…`, server `sha256:6c9af404…`
|
||||
|
||||
+38
-6
@@ -474,6 +474,16 @@ if (!tableColumns("chapters").includes("color")) {
|
||||
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
|
||||
}
|
||||
|
||||
/* A chapter orders its own notes.
|
||||
*
|
||||
* Its own column rather than the notebook's setting, because a chapter is a
|
||||
* place someone made and filled: a reference chapter can stay alphabetical
|
||||
* while the notebook around it stays in the order it was arranged. Null means
|
||||
* the hand-made order, which is what a chapter is in until it is told otherwise. */
|
||||
if (!tableColumns("chapters").includes("sort_mode")) {
|
||||
db.exec("ALTER TABLE chapters ADD COLUMN sort_mode TEXT");
|
||||
}
|
||||
|
||||
if (!tableColumns("notes").includes("chapter_id")) {
|
||||
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
|
||||
@@ -1759,16 +1769,19 @@ const cleanIcon = (value) => {
|
||||
app.get("/api/chapters", authMiddleware, (req, res) => {
|
||||
res.json(
|
||||
db
|
||||
.prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
|
||||
.prepare("SELECT id, notebook_id, name, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
|
||||
.all(req.userId)
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/chapters", authMiddleware, (req, res) => {
|
||||
const { notebookId, name, color, icon } = req.body || {};
|
||||
const { notebookId, name, color, icon, sortMode } = req.body || {};
|
||||
const label = String(name ?? "").trim();
|
||||
if (!label) return res.status(400).json({ error: "name required" });
|
||||
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
|
||||
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
|
||||
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
|
||||
}
|
||||
|
||||
// The notebook has to be this user's — otherwise a chapter could be hung off
|
||||
// someone else's, and every note filed into it would follow.
|
||||
@@ -1779,10 +1792,21 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
|
||||
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
|
||||
.get(notebookId).n;
|
||||
const id = uuid();
|
||||
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
||||
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
|
||||
db.prepare(
|
||||
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, sort_mode, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(
|
||||
id,
|
||||
req.userId,
|
||||
notebookId,
|
||||
label,
|
||||
cleanColor(color),
|
||||
cleanIcon(icon),
|
||||
order,
|
||||
sortMode ?? null,
|
||||
req.deviceId
|
||||
);
|
||||
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
|
||||
res.json({ id, notebookId, name: label, sortOrder: order });
|
||||
res.json({ id, notebookId, name: label, sortOrder: order, sortMode: sortMode ?? null });
|
||||
});
|
||||
|
||||
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
|
||||
@@ -1804,9 +1828,12 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
const { name, sortOrder, color, icon } = req.body || {};
|
||||
const { name, sortOrder, color, icon, sortMode } = req.body || {};
|
||||
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!chapter) return res.status(404).json({ error: "Not found" });
|
||||
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
|
||||
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
|
||||
}
|
||||
|
||||
let label;
|
||||
if (name !== undefined) {
|
||||
@@ -1826,6 +1853,11 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
|
||||
if (icon !== undefined) {
|
||||
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
|
||||
}
|
||||
// Also outside the COALESCE: null here means the hand-made order, not "leave
|
||||
// it as it was".
|
||||
if (sortMode !== undefined) {
|
||||
db.prepare("UPDATE chapters SET sort_mode = ? WHERE id = ?").run(sortMode ?? null, req.params.id);
|
||||
}
|
||||
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
+80
-24
@@ -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
|
||||
|
||||
@@ -303,6 +303,46 @@ try {
|
||||
await p.waitForTimeout(800);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
|
||||
|
||||
/* ── one line under another ──
|
||||
* Two lines in the same gap carry the same position, so swapping them changes
|
||||
* nothing but their sequence. The write was computed correctly and then thrown
|
||||
* away by a "did anything change?" test that sorted before comparing — and a
|
||||
* sorted comparison cannot see a sequence. The line sprang back every time. */
|
||||
await p.locator("[data-add-separator]").click();
|
||||
await p.waitForTimeout(1800);
|
||||
const lines = () =>
|
||||
p.locator("[data-separator]").evaluateAll((els) => els.map((e) => e.innerText.trim()));
|
||||
check((await p.locator("[data-separator]").count()) === 2, "a second line can be drawn");
|
||||
// First bring them together, so what follows is the case that was broken:
|
||||
// two lines in one gap, where only their sequence tells them apart.
|
||||
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
|
||||
const lineOrder = await lines();
|
||||
await drag(p.locator("[data-separator]").first(), p.locator("[data-separator]").nth(1));
|
||||
const swapped = await lines();
|
||||
check(
|
||||
swapped[0] === lineOrder[1] && swapped[1] === lineOrder[0],
|
||||
"a line dropped on another in the same gap goes under it",
|
||||
`${lineOrder.join(" | ")} → ${swapped.join(" | ")}`
|
||||
);
|
||||
// And it was written, not only drawn.
|
||||
await p.reload({ waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(2500);
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(1800);
|
||||
check(
|
||||
(await lines()).join(" | ") === swapped.join(" | "),
|
||||
"and stays under it after a reload",
|
||||
(await lines()).join(" | ")
|
||||
);
|
||||
// Back to one line, so what follows reads the notebook it expects.
|
||||
await p.locator("[data-separator]").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Remove separator").click();
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByRole("button", { name: "Remove", exact: true }).click();
|
||||
await p.waitForTimeout(2000);
|
||||
check((await p.locator("[data-separator]").count()) === 1, "and one of them can be taken away again");
|
||||
|
||||
/* ── a new note goes on top, and takes nothing with it ──
|
||||
*
|
||||
* It used to be given no position at all and left to the tie-break, which put
|
||||
|
||||
+29
-15
@@ -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}
|
||||
|
||||
@@ -242,14 +242,20 @@ export function ChapterNotes({
|
||||
undefined,
|
||||
`chapter:${chapterId ?? ""}`
|
||||
);
|
||||
// Something is in the hand, so the ends of the list need somewhere to drop it.
|
||||
const carrying = !!moving && !!dnd.draggingId && entries.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{entries.length === 0 && (
|
||||
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
|
||||
)}
|
||||
{moving && entries.length > 0 && (
|
||||
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 18 }}>
|
||||
{/* Only while something is actually being carried, and thin.
|
||||
Standing open for the whole of the mode, the two strips added half a
|
||||
finger of empty space inside every open chapter — the block grew and
|
||||
read as though the notes in it had swollen. */}
|
||||
{carrying && (
|
||||
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 12 }}>
|
||||
{dnd.overId === START_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
@@ -327,8 +333,8 @@ export function ChapterNotes({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{moving && entries.length > 0 && (
|
||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}>
|
||||
{carrying && (
|
||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 16 }}>
|
||||
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
NoteMeta,
|
||||
Notebook,
|
||||
Separator,
|
||||
SortMode,
|
||||
StrokeDoc,
|
||||
User,
|
||||
} from "../types";
|
||||
@@ -68,10 +69,14 @@ export interface NoteData {
|
||||
notebookId: string,
|
||||
name: string,
|
||||
color?: string | null,
|
||||
icon?: string | null
|
||||
icon?: string | null,
|
||||
sortMode?: SortMode | null
|
||||
) => Promise<Chapter>;
|
||||
/** Name, colour, or both — one write for one decision. */
|
||||
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
|
||||
/** Everything a chapter is, in one write — it is decided in one dialog. */
|
||||
editChapter: (
|
||||
id: string,
|
||||
updates: { name?: string; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
|
||||
) => Promise<void>;
|
||||
deleteChapter: (id: string) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
reorderNotebooks: (dragId: string, targetId: string) => void;
|
||||
@@ -283,7 +288,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
const all = notebook.separators || [];
|
||||
return {
|
||||
notes,
|
||||
all,
|
||||
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
|
||||
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
|
||||
/* Chapters are rows in this list too, and only in the notebook itself — a
|
||||
@@ -297,7 +301,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
(
|
||||
notebookId: string,
|
||||
chapterId: string | null,
|
||||
all: Separator[],
|
||||
/** This list's own lines as they were, for telling whether anything moved. */
|
||||
mine: Separator[],
|
||||
others: Separator[],
|
||||
next: Arrangement
|
||||
) => {
|
||||
@@ -320,15 +325,15 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
* that share an `updatedAt` second — so "last changed" quietly came out in
|
||||
* a different order after every drag.
|
||||
*
|
||||
* Compared as a set rather than as an array, because the merge above puts
|
||||
* the other chapters' separators first and that says nothing about whether
|
||||
* anything changed. */
|
||||
* Compared **in order**, and only this list's own lines. Sorting the
|
||||
* comparison was how a line refused to go under another one: two lines in
|
||||
* the same gap carry the same position, so swapping them changes nothing
|
||||
* but their sequence — and a sorted comparison cannot see a sequence. The
|
||||
* move was computed correctly, drawn correctly for a frame, and then
|
||||
* thrown away as "no change". */
|
||||
const fingerprint = (seps: Separator[]) =>
|
||||
seps
|
||||
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
if (fingerprint(merged) !== fingerprint(all)) {
|
||||
seps.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.color ?? ""}|${sep.style ?? ""}`).join("\n");
|
||||
if (fingerprint(next.separators) !== fingerprint(mine)) {
|
||||
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
|
||||
void sync.updateNotebook(notebookId, { separators: merged });
|
||||
}
|
||||
@@ -348,7 +353,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
if (!list) return;
|
||||
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
|
||||
if (!next) return;
|
||||
applyArrangement(notebookId, chapterId, list.all, list.others, next);
|
||||
applyArrangement(notebookId, chapterId, list.mine, list.others, next);
|
||||
},
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
@@ -372,7 +377,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
if (!list) return;
|
||||
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
|
||||
if (!next) return;
|
||||
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
|
||||
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
|
||||
},
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
@@ -394,7 +399,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
if (!list) return;
|
||||
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
|
||||
if (!next) return;
|
||||
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
|
||||
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
|
||||
},
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
@@ -479,8 +484,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
*/
|
||||
|
||||
const createChapter = useCallback(
|
||||
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
|
||||
const chapter = await sync.createChapter(notebookId, name, color, icon);
|
||||
async (
|
||||
notebookId: string,
|
||||
name: string,
|
||||
color: string | null = null,
|
||||
icon: string | null = null,
|
||||
sortMode: SortMode | null = null
|
||||
) => {
|
||||
const chapter = await sync.createChapter(notebookId, name, color, icon, sortMode);
|
||||
setChapters((p) => [...p, chapter]);
|
||||
return chapter;
|
||||
},
|
||||
@@ -492,7 +503,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
* They used to be two calls because they came from two places — a dialog and
|
||||
* a submenu — and renaming a chapter while recolouring it then meant two
|
||||
* requests for one decision. */
|
||||
const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => {
|
||||
const editChapter = useCallback(async (id: string, updates: Parameters<NoteData["editChapter"]>[1]) => {
|
||||
setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
|
||||
await sync.updateChapter(id, updates);
|
||||
}, []);
|
||||
@@ -528,7 +539,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
|
||||
)
|
||||
: // An empty one only leaves a gap to close.
|
||||
withoutRow(list.notes, list.mine, list.chapters, id);
|
||||
if (next) applyArrangement(notebookId, null, list.all, list.others, next);
|
||||
if (next) applyArrangement(notebookId, null, list.mine, list.others, next);
|
||||
},
|
||||
[applyArrangement, listOf]
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
NoteVersionSummary,
|
||||
Notebook,
|
||||
QueueEntry,
|
||||
SortMode,
|
||||
StrokeDoc,
|
||||
SyncStatus,
|
||||
User,
|
||||
@@ -86,6 +87,7 @@ interface ServerChapter {
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
sort_order?: number;
|
||||
sort_mode?: string | null;
|
||||
}
|
||||
|
||||
interface ServerNote {
|
||||
@@ -547,11 +549,25 @@ type QueuedAction =
|
||||
| { action: "deleteNotebook"; data: { id: string } }
|
||||
| {
|
||||
action: "createChapter";
|
||||
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
|
||||
data: {
|
||||
localId: string;
|
||||
notebookId: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
sortMode?: SortMode | null;
|
||||
};
|
||||
}
|
||||
| {
|
||||
action: "updateChapter";
|
||||
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
|
||||
data: {
|
||||
id: string;
|
||||
name?: string;
|
||||
sortOrder?: number;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
sortMode?: SortMode | null;
|
||||
};
|
||||
}
|
||||
| { action: "deleteChapter"; data: { id: string } }
|
||||
| {
|
||||
@@ -667,6 +683,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
name: d.name,
|
||||
color: d.color ?? null,
|
||||
icon: d.icon ?? null,
|
||||
sortMode: d.sortMode ?? null,
|
||||
});
|
||||
if (r.id !== d.localId) {
|
||||
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
|
||||
@@ -678,6 +695,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
color: d.color ?? null,
|
||||
icon: d.icon ?? null,
|
||||
sortOrder: local?.sortOrder ?? 0,
|
||||
sortMode: d.sortMode ?? null,
|
||||
});
|
||||
// Notes filed into it while it was local, and anything still queued
|
||||
// behind this create — both address a name only this device knew.
|
||||
@@ -694,6 +712,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
sortOrder: a.data.sortOrder,
|
||||
color: a.data.color,
|
||||
icon: a.data.icon,
|
||||
sortMode: a.data.sortMode,
|
||||
});
|
||||
break;
|
||||
case "deleteChapter":
|
||||
@@ -829,6 +848,10 @@ export async function fetchChapters(): Promise<Chapter[]> {
|
||||
color: c.color ?? null,
|
||||
icon: c.icon ?? null,
|
||||
sortOrder: c.sort_order || 0,
|
||||
/* Read here or it is lost: every sync replaces the local mirror with what
|
||||
this function returns, and a chapter that came back without its order
|
||||
would drop to the hand-made one seconds after being set. */
|
||||
sortMode: (c.sort_mode as SortMode | null) ?? null,
|
||||
}));
|
||||
const local = await db.getAllChapters();
|
||||
// Anything made offline is kept: its create is still in the queue, and the
|
||||
@@ -843,13 +866,14 @@ export async function createChapter(
|
||||
notebookId: string,
|
||||
name: string,
|
||||
color: string | null = null,
|
||||
icon: string | null = null
|
||||
icon: string | null = null,
|
||||
sortMode: SortMode | null = null
|
||||
): Promise<Chapter> {
|
||||
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
|
||||
if (_online && api.token) {
|
||||
try {
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
|
||||
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
|
||||
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon, sortMode });
|
||||
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder, sortMode };
|
||||
await db.putChapter(chapter);
|
||||
notifyData("chapter", chapter);
|
||||
return chapter;
|
||||
@@ -858,9 +882,9 @@ export async function createChapter(
|
||||
}
|
||||
}
|
||||
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
|
||||
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
|
||||
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, sortMode, _pendingCreate: true };
|
||||
await db.putChapter(chapter);
|
||||
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
|
||||
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
|
||||
notifyData("chapter", chapter);
|
||||
notifyStatus("pending");
|
||||
return chapter;
|
||||
@@ -868,7 +892,7 @@ export async function createChapter(
|
||||
|
||||
export async function updateChapter(
|
||||
id: string,
|
||||
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null }
|
||||
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
|
||||
): Promise<void> {
|
||||
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
|
||||
if (!chapter) return;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { CHAPTER_COLORS } from "./constants.jsx";
|
||||
import { SORT_LABELS, SORT_MODES } from "../features/notes/noteSort";
|
||||
import { IconChooser } from "./IconChooser.jsx";
|
||||
import { I } from "./icons.jsx";
|
||||
import { useBackdropClose } from "./backdrop";
|
||||
@@ -23,6 +24,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
|
||||
const [name, setName] = useState(chapter?.name || "");
|
||||
const [icon, setIcon] = useState(chapter?.icon ?? null);
|
||||
const [colour, setColour] = useState(chapter?.color ?? null);
|
||||
const [sortMode, setSortMode] = useState(chapter?.sortMode || "manual");
|
||||
const inputRef = useRef(null);
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
|
||||
@@ -46,7 +48,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
|
||||
const ready = !!name.trim();
|
||||
const save = () => {
|
||||
if (!ready) return;
|
||||
onSave({ name: name.trim(), icon, color: colour });
|
||||
onSave({ name: name.trim(), icon, color: colour, sortMode });
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -144,6 +146,37 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* A chapter orders its own notes.
|
||||
The notebook's setting governs the notebook's own list; a chapter is
|
||||
a place someone made and filled, so a reference chapter can stay
|
||||
alphabetical while the notebook around it stays as it was arranged.
|
||||
Lines and handles belong to the hand-made order and are only offered
|
||||
there — in "newest first" the notes would slide past a line. */}
|
||||
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Order</div>
|
||||
<select
|
||||
value={sortMode}
|
||||
aria-label="Chapter order"
|
||||
data-chapter-sort
|
||||
onChange={(e) => setSortMode(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "9px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.border}`,
|
||||
background: T.field,
|
||||
color: T.text,
|
||||
fontSize: 13,
|
||||
fontFamily: "inherit",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{SORT_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{SORT_LABELS[mode]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
|
||||
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user