Chapters as rows, in two views, in their own colours

Three things Marc asked for, and one of them turned out to be the hinge
the other two hang from.

**A chapter is a row like any other.** Same grip, same drag, same place
in a hand-made arrangement as a note or a separator. That needed notes
and chapters numbered from *one* counter: numbered separately they are
two sequences that cannot be interleaved again, and a chapter dragged
between two notes springs back to the top. Positions therefore have to be
named rather than read off an array index, so POST /notes/reorder now
takes `positions: [{id, order}]` beside the older `ids`, and chapters
have a reorder route of their own.

**Two ways for a chapter to open**, chosen per account beside the
typeface, for the same reason that one lives there: it is a choice about
how your own notes are laid out and it should follow you to the phone.
Beside the list, its notes in a further column the way OneNote lays out
pages — the notebook's own list carrying on to the left, chapter rows and
all. Or unfolded under the chapter's own row, indented against a rule in
its colour. A phone has room for neither and fills the list instead, with
a way back, which is what it did before there was a choice.

**Its own colour**, from eight named ones rather than the full palette:
the point of chapters is that they are different places, and twenty
near-identical blues do not help anyone tell them apart.

The colour is validated on the way in — it is written by a browser, so
only a plain #rrggbb gets through and anything else becomes no colour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 14:20:14 +02:00
1 parent 6cad632a0c
commit d74fea4905
12 files changed
+640 -88

No files matched your search

+20
View File
@@ -8,6 +8,26 @@ not from the output of `docker push`.
---
## Unreleased
**Added**
- **Two ways for a chapter to open**, chosen per account: its notes in a further
column beside the list, the way OneNote lays out its pages, or unfolded under
the chapter's own row. A phone has room for neither and fills the list, with a
way back.
- **A chapter can be given its own colour**, since the point of having them is
that they are different places.
**Changed**
- **Chapters sit at the same level as notes and separators** — same grip, same
drag, same place in a hand-made arrangement. Notes and chapters are numbered
from one counter now; separately, the two sequences could not be interleaved
and a chapter dragged between two notes would spring back. `POST
/notes/reorder` takes named positions beside the older list of ids, and
chapters have a reorder route of their own.
---
## 2026-08-12 — Moving in one press
**Images** — web `sha256:3700247c…`, server unchanged at `sha256:181d77be…`
+70 -15
View File
@@ -297,6 +297,10 @@ const signedInUser = (row) => ({ ...publicUser(row), defaultMaxVersions: MAX_VER
* kept: this column is written by the browser, so it is an input like any other,
* and a fixed shape is what stops it becoming a place to put arbitrary data. */
const NOTE_FONTS = ["serif", "sans", "mono"];
/* How a chapter opens: as a further column beside the notes, the way OneNote
* lays out its pages, or unfolded in place under its own row. A phone has room
* for neither, and falls back to filling the screen with a way back. */
const CHAPTER_VIEWS = ["column", "inline"];
function readPreferences(raw) {
let parsed = null;
@@ -306,7 +310,11 @@ function readPreferences(raw) {
parsed = null;
}
const font = parsed?.noteFont;
return { noteFont: NOTE_FONTS.includes(font) ? font : "serif" };
const view = parsed?.chapterView;
return {
noteFont: NOTE_FONTS.includes(font) ? font : "serif",
chapterView: CHAPTER_VIEWS.includes(view) ? view : "column",
};
}
// Login used to be e-mail based. Rebuild the table with a username column and
@@ -439,6 +447,7 @@ db.exec(`
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
@@ -447,6 +456,11 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
`);
/* Added after chapters shipped, so an existing table needs it too. */
if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color 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)");
@@ -725,14 +739,21 @@ app.put("/api/auth/profile", authMiddleware, (req, res) => {
});
app.put("/api/auth/preferences", authMiddleware, (req, res) => {
const { noteFont } = req.body || {};
const { noteFont, chapterView } = req.body || {};
if (noteFont !== undefined && !NOTE_FONTS.includes(noteFont)) {
return res.status(400).json({ error: `noteFont must be one of ${NOTE_FONTS.join(", ")}` });
}
if (chapterView !== undefined && !CHAPTER_VIEWS.includes(chapterView)) {
return res.status(400).json({ error: `chapterView must be one of ${CHAPTER_VIEWS.join(", ")}` });
}
const current = readPreferences(
db.prepare("SELECT preferences FROM users WHERE id = ?").get(req.userId)?.preferences
);
const next = { ...current, ...(noteFont !== undefined ? { noteFont } : {}) };
const next = {
...current,
...(noteFont !== undefined ? { noteFont } : {}),
...(chapterView !== undefined ? { chapterView } : {}),
};
db.prepare("UPDATE users SET preferences = ?, updated_at = datetime('now') WHERE id = ?")
.run(JSON.stringify(next), req.userId);
res.json(publicUser(db.prepare("SELECT * FROM users WHERE id = ?").get(req.userId)));
@@ -1124,16 +1145,27 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
*/
const MAX_REORDER = 5000;
app.post("/api/notes/reorder", authMiddleware, (req, res) => {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null;
if (!ids) return res.status(400).json({ error: "ids must be an array" });
/* Two shapes. `ids` numbers them by their place in the array, which is all
* that was ever needed while notes were the only rows. `positions` names the
* number for each — needed once chapters share the sequence, where a note's
* place in its own array is no longer its position in the list. */
const positions = Array.isArray(req.body?.positions)
? req.body.positions.slice(0, MAX_REORDER)
: Array.isArray(req.body?.ids)
? req.body.ids.slice(0, MAX_REORDER).map((id, index) => ({ id, order: index }))
: null;
if (!positions) return res.status(400).json({ error: "ids or positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?");
ids.forEach((id, index) => {
if (typeof id === "string" && id) update.run(index, id, req.userId);
});
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
const ids = positions.map((p) => p?.id);
broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId);
res.json({ success: true });
@@ -1674,16 +1706,21 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
const MAX_CHAPTER_NAME = 120;
/* A colour is written by a browser, so it is an input like any other: only a
* plain `#rrggbb` gets through, and anything else becomes "no colour". */
const cleanColor = (value) =>
typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value.toLowerCase() : null;
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.prepare("SELECT id, notebook_id, name, color, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name } = req.body || {};
const { notebookId, name, color } = 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" });
@@ -1697,14 +1734,32 @@ 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, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, order, req.deviceId);
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, color, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, cleanColor(color), order, req.deviceId);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
});
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
const positions = Array.isArray(req.body?.positions) ? req.body.positions.slice(0, MAX_REORDER) : null;
if (!positions) return res.status(400).json({ error: "positions must be an array" });
const write = db.transaction(() => {
const update = db.prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND user_id = ?");
for (const p of positions) {
if (p && typeof p.id === "string" && p.id && Number.isFinite(Number(p.order))) {
update.run(Math.max(0, Math.round(Number(p.order))), p.id, req.userId);
}
}
});
write();
broadcastToUser(req.userId, { type: "chapters_reordered", data: { count: positions.length } }, req.deviceId);
res.json({ success: true });
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder } = req.body || {};
const { name, sortOrder, color } = 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" });
@@ -1714,8 +1769,8 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
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" });
}
db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
.run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
db.prepare("UPDATE chapters SET name = COALESCE(?, name), color = COALESCE(?, color), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
.run(label ?? null, color === undefined ? null : cleanColor(color), sortOrder ?? null, req.deviceId, req.params.id);
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
+69 -7
View File
@@ -75,27 +75,85 @@ try {
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", "));
check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out");
const inChapter = () =>
p.locator("[data-chapter-note]").evaluateAll((els) => els.map((e) => e.textContent.trim()));
check((await inChapter()).join(",").includes("Stromrechnung"), "and is inside the chapter", (await inChapter()).join(", "));
check((await p.locator("[data-chapter-close]").count()) === 1, "with a way to close it again");
const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung");
check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id));
/* A note made inside a chapter belongs to it, without being moved. */
/* A note made while a chapter is open belongs to it, without being moved. */
await createNote(p);
await setTitle(p, "Wasserrechnung");
await p.waitForTimeout(1500);
const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung");
check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id));
check(made?.chapter_id === filed?.chapter_id, "a note made while it is open is filed there", String(made?.chapter_id));
await p.locator("[data-chapter-back]").click();
await p.locator("[data-chapter-close]").click();
await p.waitForTimeout(1200);
check(
(await titles()).join(",") === "Zählerstand",
"and going back shows the notebook again, without the chapter's notes",
"and the notebook's own list never held the chapter's notes",
(await titles()).join(", ")
);
/* ── the two ways a chapter opens ──
* A further column beside the list, the way OneNote lays out its pages, or
* unfolded under its own row. Chosen on the account, so the answer follows to
* the phone — where there is room for neither and the chapter fills the
* screen instead. */
await addChapter("Verträge");
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await p.locator("[data-chapter-column]").count()) === 1, "a chapter opens beside the list by default");
check(
(await p.locator("[data-chapter-row]").count()) === 2,
"and the notebook's own list keeps every chapter row",
String(await p.locator("[data-chapter-row]").count())
);
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
await p.locator('[data-chapter-view="inline"]').click();
await p.waitForTimeout(1500);
await p.mouse.click(1380, 900); // outside the panel
await p.waitForTimeout(1200);
await p.locator("[data-chapter-row]").first().click();
await p.waitForTimeout(1200);
check((await p.locator("[data-chapter-notes]").count()) === 1, "the other setting unfolds it under its row");
check((await p.locator("[data-chapter-column]").count()) === 0, "and there is no column then");
const me = await api(p, "GET", "/auth/me");
check(me.body?.preferences?.chapterView === "inline", "the choice is on the account, not in this browser");
/* ── a chapter is a row like any other ──
* Same level as notes and separators, so it can be dragged into place in the
* arrangement rather than being stuck above everything. */
await p.locator("[data-sort-button]").click();
await p.waitForTimeout(300);
await p.locator('[data-sort-option="manual"]').click();
await p.waitForTimeout(1200);
check(
(await p.locator("[data-reorder-grip]").count()) >= 2,
"a chapter has a grip like a note does",
String(await p.locator("[data-reorder-grip]").count())
);
/* ── its own colour ── */
await p.locator("[data-chapter-row]").first().click({ button: "right", force: true });
await p.waitForTimeout(600);
await p.locator("[data-submenu-toggle]").filter({ hasText: "Colour" }).first().click();
await p.waitForTimeout(500);
await p.locator("[data-submenu] button").filter({ hasText: "Green" }).first().click();
await p.waitForTimeout(2000);
const coloured = (await api(p, "GET", "/chapters")).body;
check(
coloured.some((c) => c.color === "#2da44e"),
"a chapter can be given its own colour",
JSON.stringify(coloured.map((c) => [c.name, c.color]))
);
/* ── a chapter is not a wastebasket ──
* Removing one must never remove what it held: the notes come back to the
* notebook, where they can be seen and filed again. */
@@ -107,7 +165,11 @@ try {
await p.getByRole("button", { name: "Remove", exact: true }).click();
await p.waitForTimeout(2500);
check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone");
check(
(await p.locator("[data-chapter-row]").count()) === 1,
"the chapter is gone, and only that one",
String(await p.locator("[data-chapter-row]").count())
);
const after = (await titles()).sort().join(",");
check(
after === "Stromrechnung,Wasserrechnung,Zählerstand",
+203 -4
View File
@@ -8,7 +8,7 @@ import { usePwaInstall } from "./hooks/usePwaInstall";
import { useNotebookDnd } from "./hooks/useNotebookDnd";
import { useNavigationHistory } from "./hooks/useNavigationHistory";
import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { CHAPTER_COLORS, DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx";
import { I } from "./ui/icons.jsx";
import { makeTheme, FONT_UI } from "./ui/theme";
import { ConfirmDialog } from "./ui/ConfirmDialog.jsx";
@@ -103,6 +103,12 @@ export default function App() {
// Presentation only, and it lives on the account so it follows to the phone.
const noteFont = user?.preferences?.noteFont || "serif";
/* How a chapter opens: a further column beside the list, or unfolded under
its own row. A phone has room for neither, so there it fills the screen —
which is what "column" already did before there was a choice. */
const chapterView = user?.preferences?.chapterView || "column";
const inlineChapters = chapterView === "inline" && !isMobile;
const columnChapters = chapterView === "column" && !isMobile;
const data = useNoteData(user, activeNote);
const { notes, notebooks, noteContents, backlinks } = data;
@@ -287,7 +293,8 @@ export default function App() {
if (activeFolder === "trash") list = trashedNotes;
else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred);
else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes;
else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter);
else if (activeChapter && !inlineChapters && !columnChapters)
list = activeNotes.filter((n) => n.chapterId === activeChapter);
/* In the notebook itself, a note filed into a chapter is *in* that chapter
and not loose here — otherwise it would appear twice and moving it would
look like it had done nothing. */
@@ -305,7 +312,24 @@ export default function App() {
return list.filter((n) => n.title.toLowerCase().includes(q));
}
return sortNotes(list, sortMode);
}, [activeFolder, activeChapter, searchQuery, search.results, activeNotes, trashedNotes, sortMode]);
}, [
activeFolder,
activeChapter,
inlineChapters,
columnChapters,
searchQuery,
search.results,
activeNotes,
trashedNotes,
sortMode,
]);
/* The notes of the chapter being looked into, in the same order they would
have in a list of their own. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), sortMode) : []),
[activeChapter, activeNotes, sortMode]
);
const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null;
// The editor opens as soon as the metadata is known; the body fills in when
@@ -603,6 +627,18 @@ export default function App() {
onSave: (name) => data.renameChapter(chapter.id, name),
}),
},
{
label: "Colour",
icon: I.edit,
submenu: [
{ label: "None", colorDot: T.faint, action: () => data.recolourChapter(chapter.id, null) },
...CHAPTER_COLORS.map((c) => ({
label: c.label,
colorDot: c.value,
action: () => data.recolourChapter(chapter.id, c.value),
})),
],
},
{ divider: true },
{
label: "Remove chapter",
@@ -680,6 +716,58 @@ export default function App() {
if (!user) return <AuthScreen onLogin={onLogin} dark={dark} />;
// On a phone the list and the editor take turns; on a desktop both are shown.
/* A chapter's notes, unfolded under its own row. Indented and against a rule,
so it reads as "inside this" rather than as more of the same list. */
const renderChapterNotes = (chapter) => (
<div
data-chapter-notes={chapter.id}
style={{ margin: "2px 0 8px 22px", borderLeft: `2px solid ${chapter.color || T.accent}55`, paddingLeft: 8 }}
>
{chapterNotes.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "8px 10px",
borderRadius: 8,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
);
const showList = !(isMobile && mobileView === "editor" && activeNote);
const showEditor = !isMobile || mobileView === "editor";
@@ -775,10 +863,15 @@ export default function App() {
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
chapters={folderChapters}
openChapter={openChapter}
/* Only in the fallback: there the list becomes the chapter, and
its header is the way back. Beside or below, the notebook's own
list carries on and keeps its chapter rows. */
openChapter={inlineChapters || columnChapters ? null : openChapter}
onOpenChapter={setActiveChapter}
onAddChapter={activeNotebook ? addChapter : null}
onChapterContextMenu={chapterContextMenu}
expandedChapter={inlineChapters ? activeChapter : null}
renderChapterNotes={inlineChapters ? renderChapterNotes : null}
onMoveNote={setMovingNote}
onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
@@ -790,6 +883,112 @@ export default function App() {
onEmptyTrash={emptyTrash}
/>
)}
{/* The chapter's own column, beside the list — the way OneNote lays
out its pages. Only where there is room: on a phone the chapter
fills the screen instead, with a way back. */}
{columnChapters && openChapter && (
<div
data-chapter-column
style={{
width: 268,
flexShrink: 0,
display: "flex",
flexDirection: "column",
borderRight: `1px solid ${T.borderLight}`,
background: T.panel || "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "14px 14px 10px",
borderBottom: `1px solid ${T.borderLight}`,
}}
>
<span style={{ display: "flex", color: openChapter.color || T.accent, flexShrink: 0 }}>
{I.books}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 14,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{openChapter.name}
</span>
<button
onClick={() => setActiveChapter(null)}
title="Close chapter"
aria-label="Close chapter"
data-chapter-close
style={{
border: "none",
background: "transparent",
color: T.muted,
cursor: "pointer",
display: "flex",
padding: 0,
}}
>
{I.x}
</button>
</div>
<div style={{ flex: 1, overflowY: "auto", padding: 6 }}>
{chapterNotes.length === 0 && (
<div style={{ padding: "12px 10px", fontSize: 12, color: T.muted }}>
Nothing in here yet.
</div>
)}
{chapterNotes.map((n) => (
<button
key={n.id}
data-chapter-note={n.id}
onClick={() => openNote(n.id)}
onContextMenu={(e) => noteContextMenu(e, n)}
style={{
display: "flex",
alignItems: "center",
gap: 7,
width: "100%",
padding: "9px 10px",
borderRadius: 9,
border: `1px solid ${activeNote === n.id ? T.accent + "55" : "transparent"}`,
background: activeNote === n.id ? T.accentSoft : "transparent",
cursor: "pointer",
textAlign: "left",
marginBottom: 2,
}}
>
<span style={{ display: "flex", color: T.faint, flexShrink: 0 }}>
{n.type === "pdf" ? I.pdf : I.note}
</span>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 12.5,
fontWeight: 600,
color: T.text,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{n.title}
</span>
</button>
))}
</div>
</div>
)}
{showEditor && (
<EditorPane
T={T}
+39 -20
View File
@@ -19,8 +19,9 @@ const iconButton = (T) => ({
color: T.muted,
});
/** A note rendered outside the manual order, wrapped to look like an entry. */
/** A row rendered outside the manual order, wrapped to look like an entry. */
const asNoteEntry = (note) => ({ kind: "note", id: note.id, note });
const asChapterEntry = (chapter) => ({ kind: "chapter", id: chapter.id, chapter });
/**
* Where the dragged row will land.
@@ -211,7 +212,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
* place, and the notes are what is in this one. The count is what makes it worth
* opening — a row saying only "Invoices" tells you nothing about whether to.
*/
function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
return (
<button
data-chapter-row={chapter.id}
@@ -221,17 +222,20 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
display: "flex",
alignItems: "center",
gap: 9,
width: "100%",
flex: 1,
minWidth: 0,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${T.borderLight}`,
background: "transparent",
border: `1px solid ${open ? T.accent + "55" : T.borderLight}`,
background: open ? T.accentSoft : "transparent",
cursor: "pointer",
marginBottom: 4,
textAlign: "left",
}}
>
<span style={{ display: "flex", color: T.accent, flexShrink: 0 }}>{I.books}</span>
{/* Its own colour, so a shelf of chapters can be told apart at a glance
— the point of having them is that they are different places. */}
<span style={{ display: "flex", color: chapter.color || T.accent, flexShrink: 0 }}>{I.books}</span>
<span
style={{
flex: 1,
@@ -247,7 +251,7 @@ function ChapterRow({ T, chapter, onOpen, onContextMenu }) {
{chapter.name}
</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{chapter.noteCount}</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>›</span>
<span style={{ fontSize: 11, color: T.faint, flexShrink: 0 }}>{open ? "▾" : "›"}</span>
</button>
);
}
@@ -453,6 +457,8 @@ export function NoteList({
onOpenChapter,
onAddChapter,
onChapterContextMenu,
expandedChapter,
renderChapterNotes,
onMoveNote,
onReorder,
onToggleSidebar,
@@ -471,6 +477,9 @@ export function NoteList({
or the trash, which are views over notebooks rather than a place to file
anything, and not inside a chapter, because a chapter holds notes. */
const canAddChapter = !!onAddChapter && !openChapter;
/* `openChapter` means this list *is* the chapter — the fallback where there is
no room beside or below, so the chapter fills the list and the header offers
a way back. In the other two views the chapter rows stay where they are. */
const shownChapters = openChapter ? [] : chapters || [];
return (
@@ -705,19 +714,10 @@ export function NoteList({
something there: in "newest first" the notes would slide past a
line on every edit. They are not shown in the other orders, and
are still in place on the way back. */}
{shownChapters.map((chapter) => (
<ChapterRow
key={chapter.id}
T={T}
chapter={chapter}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
))}
{shownChapters.length > 0 && notes.length > 0 && (
<div style={{ height: 1, background: T.borderLight, margin: "8px 4px 10px" }} />
)}
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
{(reorderable
? mergeEntries(notes, separators, shownChapters)
: [...shownChapters.map(asChapterEntry), ...notes.map(asNoteEntry)]
).map((entry) => {
const showDropLine = reorderable && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
*
@@ -728,6 +728,25 @@ export function NoteList({
const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
const lifted = dnd.draggingId === entry.id;
if (entry.kind === "chapter") {
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
{showDropLine && <DropLine T={T} />}
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{reorderable && <Grip T={T} {...dnd.gripProps} />}
<ChapterRow
T={T}
chapter={entry.chapter}
open={expandedChapter === entry.id}
onOpen={onOpenChapter}
onContextMenu={onChapterContextMenu}
/>
</div>
{expandedChapter === entry.id && renderChapterNotes?.(entry.chapter)}
</div>
);
}
if (entry.kind === "separator") {
return (
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
+67 -26
View File
@@ -12,7 +12,7 @@
* scramble everything that was not being looked at.
*/
import type { NoteMeta, Separator, SortMode } from "../../types";
import type { Chapter, NoteMeta, Separator, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
@@ -86,6 +86,7 @@ export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): Not
*/
export type Entry =
| { kind: "note"; id: string; order: number; note: NoteMeta }
| { kind: "chapter"; id: string; order: number; chapter: Chapter }
| { kind: "separator"; id: string; order: number; separator: Separator };
/**
@@ -94,51 +95,90 @@ export type Entry =
* A separator sitting at the same position as a note comes first, so "insert a
* line above this note" lands where it was asked for rather than one row late.
*/
export function mergeEntries(notes: NoteMeta[], separators: Separator[] = []): Entry[] {
/* Notes are placed by their rank, not by their stored number. Notes that have
* never been dragged all carry 0, and sorting has already broken that tie —
* using the stored numbers here would pile them all at position 0 and push
* every separator behind the lot. */
const noteEntries: Entry[] = sortNotes(notes, "manual").map((note, rank) => ({
kind: "note" as const,
id: note.id,
order: rank,
note,
}));
export function mergeEntries(
notes: NoteMeta[],
separators: Separator[] = [],
chapters: Chapter[] = []
): Entry[] {
/* Notes and chapters share one sequence.
*
* A chapter is a row in the list like a note is, and can be dragged among
* them, so they cannot each be numbered from zero — they are ranked together
* by the position each carries.
*
* Ranked, not placed by the stored number: rows that have never been dragged
* all carry 0, and using the raw numbers would pile them at position 0 and
* push every separator behind the lot. */
const ranked = sortNotes(notes, "manual");
const rows = [
...chapters.map((chapter, i) => ({
kind: "chapter" as const,
id: chapter.id,
chapter,
sort: chapter.sortOrder ?? 0,
// Keeps the order within one kind settled when several share a position,
// which every row does until it has been dragged once.
tie: i,
})),
...ranked.map((note, i) => ({ kind: "note" as const, id: note.id, note, sort: note.sortOrder ?? 0, tie: i })),
].sort(
(a, b) =>
a.sort - b.sort ||
// A chapter shares a position with a note only before either has been
// moved; putting chapters first there matches where they are created.
(a.kind === b.kind ? a.tie - b.tie : a.kind === "chapter" ? -1 : 1)
);
const rowEntries: Entry[] = rows.map((row, rank) =>
row.kind === "chapter"
? { kind: "chapter", id: row.id, order: rank, chapter: row.chapter }
: { kind: "note", id: row.id, order: rank, note: row.note }
);
const separatorEntries: Entry[] = separators.map((separator) => ({
kind: "separator" as const,
id: separator.id,
// Clamped, so a separator left over from a notebook that has since shrunk
// still appears — at the end — rather than vanishing with its label.
order: Math.max(0, Math.min(separator.order, noteEntries.length)),
order: Math.max(0, Math.min(separator.order, rowEntries.length)),
separator,
}));
return [...noteEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a note comes first, so
// "a line above this note" lands where it was asked for.
return [...rowEntries, ...separatorEntries].sort(
// A separator sitting at the same position as a row comes first, so
// "a line above this one" lands where it was asked for.
(a, b) => a.order - b.order || (a.kind === "separator" ? -1 : 1)
);
}
/** The result of a drag: what to write back for notes and for separators. */
/** The result of a drag: what to write back for each kind of row. */
export interface Arrangement {
noteIds: string[];
/** Notes in their new order, with the position each should carry. */
notes: Array<{ id: string; order: number }>;
/** Chapters likewise — numbered from the *same* counter as the notes. */
chapters: Array<{ id: string; order: number }>;
separators: Separator[];
/** The note ids alone, for callers that only reorder notes. */
noteIds: string[];
}
/** Turns an ordered entry list back into storable positions. */
export function toArrangement(entries: Entry[]): Arrangement {
const noteIds: string[] = [];
const notes: Array<{ id: string; order: number }> = [];
const chapters: Array<{ id: string; order: number }> = [];
const separators: Separator[] = [];
/* One counter for notes and chapters together. Numbering them separately
* would leave two sequences that cannot be interleaved again, and a chapter
* dragged between two notes would spring back. */
let position = 0;
for (const entry of entries) {
if (entry.kind === "note") noteIds.push(entry.id);
// A separator's position is "how many notes come before me". Expressed that
// way it stays put as the notes around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: noteIds.length });
if (entry.kind === "note") notes.push({ id: entry.id, order: position++ });
else if (entry.kind === "chapter") chapters.push({ id: entry.id, order: position++ });
// A separator's position is "how many rows come before me". Expressed that
// way it stays put as the rows around it are renamed, edited or opened.
else separators.push({ ...entry.separator, order: position });
}
return { noteIds, separators };
return { notes, chapters, separators, noteIds: notes.map((n) => n.id) };
}
/**
@@ -151,10 +191,11 @@ export function reindexEntries(
notes: NoteMeta[],
separators: Separator[],
dragId: string,
targetId: string
targetId: string,
chapters: Chapter[] = []
): Arrangement | null {
if (dragId === targetId) return null;
const entries = mergeEntries(notes, separators);
const entries = mergeEntries(notes, separators, chapters);
const from = entries.findIndex((e) => e.id === dragId);
if (from < 0) return null;
+23 -3
View File
@@ -54,6 +54,7 @@ export interface NoteData {
chapters: Chapter[];
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
renameChapter: (id: string, name: string) => Promise<void>;
recolourChapter: (id: string, color: string | null) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
@@ -79,6 +80,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
notesRef.current = notes;
const notebooksRef = useRef(notebooks);
notebooksRef.current = notebooks;
const chaptersRef = useRef(chapters);
chaptersRef.current = chapters;
const contentsRef = useRef(noteContents);
contentsRef.current = noteContents;
@@ -300,14 +303,25 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const all = notebook.separators || [];
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
/* Chapters are rows in this list too, and only in the notebook itself — a
chapter has no chapters of its own. */
const inList = chapterId
? []
: chaptersRef.current.filter((c) => c.notebookId === notebookId);
const next = reindexEntries(inNotebook, mine, dragId, targetId);
const next = reindexEntries(inNotebook, mine, dragId, targetId, inList);
if (!next) return;
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.noteIds.map((id, i) => [id, i]));
const position = new Map(next.notes.map((n) => [n.id, n.order]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.noteIds);
void sync.reorderNotes(next.notes);
if (next.chapters.length) {
const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order]));
setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c)));
void sync.reorderChapters(next.chapters);
}
/* Only written when it actually changed.
*
@@ -353,6 +367,11 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
await sync.updateChapter(id, { name });
}, []);
const recolourChapter = useCallback(async (id: string, color: string | null) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, color } : c)));
await sync.updateChapter(id, { color });
}, []);
const deleteChapter = useCallback(
async (id: string) => {
await sync.deleteChapter(id);
@@ -416,6 +435,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
chapters,
createChapter,
renameChapter,
recolourChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
+42 -8
View File
@@ -83,6 +83,7 @@ interface ServerChapter {
id: string;
notebook_id: string;
name: string;
color?: string | null;
sort_order?: number;
}
@@ -530,7 +531,7 @@ type QueuedAction =
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createChapter"; data: { localId: string; notebookId: string; name: string } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number } }
| { action: "updateChapter"; data: { id: string; name?: string; sortOrder?: number; color?: string | null } }
| { action: "deleteChapter"; data: { id: string } }
| {
action: "createNote";
@@ -660,7 +661,11 @@ async function processEntry(entry: QueueEntry): Promise<void> {
break;
}
case "updateChapter":
await api.put(`/chapters/${a.data.id}`, { name: a.data.name, sortOrder: a.data.sortOrder });
await api.put(`/chapters/${a.data.id}`, {
name: a.data.name,
sortOrder: a.data.sortOrder,
color: a.data.color,
});
break;
case "deleteChapter":
await api.del(`/chapters/${a.data.id}`);
@@ -792,6 +797,7 @@ export async function fetchChapters(): Promise<Chapter[]> {
id: c.id,
notebookId: c.notebook_id,
name: c.name,
color: c.color ?? null,
sortOrder: c.sort_order || 0,
}));
const local = await db.getAllChapters();
@@ -825,7 +831,10 @@ export async function createChapter(notebookId: string, name: string): Promise<C
return chapter;
}
export async function updateChapter(id: string, updates: { name?: string; sortOrder?: number }): Promise<void> {
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
await db.putChapter({ ...chapter, ...updates });
@@ -998,15 +1007,40 @@ export async function getNotes(): Promise<NoteMeta[]> {
* note update would be wrong for the same reason — it is sent when there is a
* connection and simply re-derived from the local order otherwise.
*/
export async function reorderNotes(orderedIds: string[]): Promise<void> {
/**
* Writes new positions for notes.
*
* Positions are named rather than taken from the array index: chapters share
* the sequence, so a note's place among the notes is no longer its place in the
* list. A bare list of ids still works and is numbered in order.
*/
export async function reorderNotes(
order: string[] | Array<{ id: string; order: number }>
): Promise<void> {
const positions = order.map((entry, i) =>
typeof entry === "string" ? { id: entry, order: i } : entry
);
const notes = await db.getAllNotes();
for (let i = 0; i < orderedIds.length; i++) {
const note = notes.find((n) => n.id === orderedIds[i]);
if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i });
for (const p of positions) {
const note = notes.find((n) => n.id === p.id);
if (note && note.sortOrder !== p.order) await db.putNote({ ...note, sortOrder: p.order });
}
notifyData("notes", null);
if (_online && api.token) {
api.post("/notes/reorder", { ids: orderedIds }).catch(() => {});
api.post("/notes/reorder", { positions }).catch(() => {});
}
}
/** The same, for chapters — they are rows in that one sequence too. */
export async function reorderChapters(positions: Array<{ id: string; order: number }>): Promise<void> {
const chapters = await db.getAllChapters();
for (const p of positions) {
const chapter = chapters.find((c) => c.id === p.id);
if (chapter && chapter.sortOrder !== p.order) await db.putChapter({ ...chapter, sortOrder: p.order });
}
notifyData("chapters", null);
if (_online && api.token) {
api.post("/chapters/reorder", { positions }).catch(() => {});
}
}
+12
View File
@@ -105,6 +105,8 @@ export interface Chapter {
id: string;
notebookId: string;
name: string;
/** `#rrggbb` for its icon, so a shelf of chapters can be told apart. */
color?: string | null;
sortOrder: number;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
@@ -129,8 +131,18 @@ export interface Notebook {
/** How the note body is set. Presentation only — the export is unaffected. */
export type NoteFont = "serif" | "sans" | "mono";
/**
* How a chapter opens.
*
* "column" puts its notes in a further column beside the list, the way OneNote
* lays out its pages; "inline" unfolds them under the chapter's own row. A
* phone has room for neither and fills the screen either way, with a way back.
*/
export type ChapterView = "column" | "inline";
export interface UserPreferences {
noteFont: NoteFont;
chapterView?: ChapterView;
}
export interface User {
+45 -2
View File
@@ -4,13 +4,15 @@ import { I } from "./icons.jsx";
/* ═══════ ACCOUNT MENU ═══════ */
export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin, onExportAll, dark }) {
const chooseFont = async (noteFont) => {
const choosePreference = async (change) => {
try {
onUserUpdate(await sync.updatePreferences({ noteFont }));
onUserUpdate(await sync.updatePreferences(change));
} catch {
/* the choice is presentation; failing to store it must not block the panel */
}
};
const chooseFont = (noteFont) => choosePreference({ noteFont });
const chooseChapterView = (chapterView) => choosePreference({ chapterView });
const [tab, setTab] = useState("profile"); // profile | username | password
const [displayName, setDisplayName] = useState(user?.displayName || "");
const [newUsername, setNewUsername] = useState(user?.username || "");
@@ -305,6 +307,47 @@ export function AccountMenu({ user, onClose, onLogout, onUserUpdate, onOpenAdmin
})}
</div>
</div>
{/* How a chapter opens. Kept beside the typeface because both are
the same kind of thing: how your own notes are laid out, stored
on the account so the answer follows you to the phone. */}
<div style={{ marginTop: 18 }}>
<label style={{ fontSize: 12, color: T.muted, marginBottom: 6, display: "block" }}>
Chapters open
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, 1fr)", gap: 8 }}>
{[
["column", "Beside the list", "Its notes in a further column, the way OneNote lays out pages"],
["inline", "Under the chapter", "Its notes unfold in place, below the chapter's own row"],
].map(([value, label, hint]) => {
const active = (user.preferences?.chapterView || "column") === value;
return (
<button
key={value}
data-chapter-view={value}
onClick={() => chooseChapterView(value)}
title={hint}
style={{
padding: "10px 8px",
borderRadius: 10,
border: `1.5px solid ${active ? T.accent : T.border}`,
background: active ? T.accent + "15" : "transparent",
color: active ? T.accent : T.text,
cursor: "pointer",
fontSize: 12.5,
textAlign: "left",
lineHeight: 1.35,
}}
>
{label}
</button>
);
})}
</div>
<div style={{ fontSize: 11, color: T.muted, marginTop: 6, lineHeight: 1.5 }}>
A phone has room for neither, and fills the screen either way — with a way back.
</div>
</div>
</>
)}
+18
View File
@@ -41,6 +41,24 @@ export const COVERS = [
{ bg: "linear-gradient(135deg,#654ea3,#eaafc8)", spine: "#4e3c7e", accent: "#f8bbd0" },
];
/**
* Colours a chapter's icon can be given.
*
* A short, named list rather than the full palette: the point is telling one
* chapter from another at a glance, and twenty near-identical blues do not help
* with that. Each is legible on both themes.
*/
export const CHAPTER_COLORS = [
{ label: "Red", value: "#e5534b" },
{ label: "Orange", value: "#e07c24" },
{ label: "Yellow", value: "#d4a017" },
{ label: "Green", value: "#2da44e" },
{ label: "Teal", value: "#1f9c96" },
{ label: "Blue", value: "#4a8ff5" },
{ label: "Purple", value: "#a371f7" },
{ label: "Pink", value: "#db61a2" },
];
/* The icon is a key rather than an emoji: the sidebar draws it from the app's
* own set, so it matches the stroke weight of everything around it and looks
* the same on every machine. */
+32 -3
View File
@@ -432,7 +432,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `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` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
| `chapters` | 13 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
| `chapters` | 21 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back |
| `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 |
| `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 |
@@ -483,7 +483,7 @@ the left edge. Centred, the same leftover space reads as two margins and needs n
explaining.
**The typeface is a per-account setting** (Account → Profile): serif, sans or
mono. On the account rather than in the browser, because a choice about how your
mono. How a chapter opens is chosen there too, for the same reason. On the account rather than in the browser, because a choice about how your
own notes are set should follow you to the phone rather than be made again on
every device. The server accepts those three values and drops anything else —
that column is written by a browser, so it is an input like any other.
@@ -971,7 +971,36 @@ Three rules that keep the two levels honest:
A note made while a chapter is open is filed there without being moved. A chapter
is named when it is created, unlike a separator: a separator is a line and reads
perfectly well unnamed, while an unnamed chapter is a row nobody can tell from
the next one.
the next one. Each can be given **its own colour** — the point of having chapters
is that they are different places, and eight named colours tell them apart where
twenty near-identical blues would not.
### How a chapter opens
Two ways, chosen per account under Account → Profile, beside the typeface and for
the same reason: it is a choice about how your own notes are laid out, and it
should follow you to the phone.
- **Beside the list** — its notes in a further column, the way OneNote lays out
its pages. The notebook's own list carries on to the left, chapter rows and
all.
- **Under the chapter** — its notes unfold in place, indented against a rule in
the chapter's colour.
A phone has room for neither. There the chapter fills the list and the header
button becomes a way back out, which is what it did before there was a choice.
### A chapter is a row like any other
Chapters sit at the same level as notes and separators: same grip, same drag,
same place in a hand-made arrangement. That takes **one counter for both** in
`toArrangement` — numbering notes and chapters separately would leave two
sequences that cannot be interleaved again, and a chapter dragged between two
notes would spring back to the top.
Positions therefore have to be *named* rather than taken from an array index,
which is why `POST /notes/reorder` accepts `positions: [{id, order}]` beside the
older `ids`, and why chapters have a reorder route of their own.
---