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
|
## 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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user