Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0f2184e6 | ||
|
|
3b7958b713 |
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: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
|
||||
|
||||
**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