A chapter with an order of its own

A chapter orders its own notes now, chosen in the same dialog as its name,
mark and colour -- one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and
filled, so a reference chapter can stay alphabetical while the notebook
around it stays as it was arranged. It starts hand-made, and a chapter put
in one of the other orders loses its lines and its handles exactly as the
notebook does: neither means anything against an order the app is choosing.

A line still would not go under another line, and the comparator was only
half of it. The write was then thrown away by the "did anything change?"
test, which sorted the separators before comparing them -- to ignore the
other chapters' lines riding along in the same field -- and a sorted
comparison cannot see a sequence. Two lines in one gap carry the same
position, so swapping them changes nothing else. It compares this list's own
lines, in order.

An open chapter swelled while rearranging: the strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty
space inside every chapter. They are thin now, and only there while
something is actually being carried -- which is also why the suite presses
first and aims afterwards.

Server: sort_mode on chapters, validated against the same five modes as a
notebook's, and set outside the COALESCE so null means the hand-made order
rather than "leave it alone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 16:45:05 +02:00
1 parent ea1bca5d25
commit 3b7958b713
11 files changed
+354 -94

No files matched your search

+29 -15
View File
@@ -368,12 +368,16 @@ export default function App() {
const isSearching = searchQuery.trim().length > 0;
/* The order a chapter's own notes are in — its own setting, chosen in its
dialog, and the hand-made one until it is given another. */
const chapterSort = openChapter?.sortMode || DEFAULT_NOTEBOOK_SORT;
/* Whether an arrangement is in effect, and so whether there is anything to
rearrange. A chapter always has one; the notebook's own list has one when it
is in the hand-made order. Searching suspends both — dragging against a
narrowed list would renumber notes it is hiding. */
const listReorderable = !!activeNotebook && !isSearching && (!!listChapter || sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter;
rearrange. Searching suspends it either way — dragging against a narrowed
list would renumber notes it is hiding. */
const listReorderable =
!!activeNotebook && !isSearching && (listChapter ? chapterSort === "manual" : sortMode === "manual");
const chapterReorderable = !!activeNotebook && !isSearching && !!activeChapter && chapterSort === "manual";
const listKey = `${activeFolder}:${listChapter ?? ""}`;
useEffect(() => {
setReordering(false);
@@ -390,12 +394,13 @@ export default function App() {
look like it had done nothing. */
else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId);
/* Inside a chapter the order is always the user's own.
/* A chapter carries its own order.
*
* The mode belongs to the notebook and governs the notebook's own list; a
* chapter is a place someone made and filled by hand, and filing a note into
* one only means something if it stays where it was put. */
const mode = listChapter ? "manual" : sortMode;
* The notebook's setting governs the notebook's own list; a chapter is a
* place someone made and filled, so it keeps its own — hand-made unless it
* was told otherwise, because filing a note into one only means something if
* it stays where it was put. */
const mode = listChapter ? chapterSort : sortMode;
if (searchQuery.trim()) {
// A search reads every note's text, so results keep the index's relevance
@@ -417,13 +422,16 @@ export default function App() {
activeNotes,
trashedNotes,
sortMode,
chapterSort,
]);
/* The notes of the chapter being looked into, in the order they were put in.
Always the hand-made one, as in the list — see `filteredNotes`. */
/* The notes of the chapter being looked into, in the order it is set to. */
const chapterNotes = useMemo(
() => (activeChapter ? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), "manual") : []),
[activeChapter, activeNotes]
() =>
activeChapter
? sortNotes(activeNotes.filter((n) => n.chapterId === activeChapter), chapterSort)
: [],
[activeChapter, activeNotes, chapterSort]
);
/* And the lines drawn inside it, which are its own — the notebook's stay in
the notebook, and each chapter's stay in that chapter. */
@@ -1227,7 +1235,13 @@ export default function App() {
onSave={(fields) =>
editingChapter.id
? data.editChapter(editingChapter.id, fields)
: data.createChapter(activeNotebook.id, fields.name, fields.color, fields.icon)
: data.createChapter(
activeNotebook.id,
fields.name,
fields.color,
fields.icon,
fields.sortMode
)
}
onClose={() => setEditingChapter(null)}
dark={dark}
+10 -4
View File
@@ -242,14 +242,20 @@ export function ChapterNotes({
undefined,
`chapter:${chapterId ?? ""}`
);
// Something is in the hand, so the ends of the list need somewhere to drop it.
const carrying = !!moving && !!dnd.draggingId && entries.length > 0;
return (
<>
{entries.length === 0 && (
<div style={{ padding: "8px 10px", fontSize: 12, color: T.muted }}>Nothing in here yet.</div>
)}
{moving && entries.length > 0 && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 18 }}>
{/* Only while something is actually being carried, and thin.
Standing open for the whole of the mode, the two strips added half a
finger of empty space inside every open chapter — the block grew and
read as though the notes in it had swollen. */}
{carrying && (
<div data-drop-start {...dnd.rowProps(START_ID)} style={{ minHeight: 12 }}>
{dnd.overId === START_ID && <DropLine T={T} />}
</div>
)}
@@ -327,8 +333,8 @@ export function ChapterNotes({
</div>
)
)}
{moving && entries.length > 0 && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}>
{carrying && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 16 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
)}
+31 -20
View File
@@ -13,6 +13,7 @@ import type {
NoteMeta,
Notebook,
Separator,
SortMode,
StrokeDoc,
User,
} from "../types";
@@ -68,10 +69,14 @@ export interface NoteData {
notebookId: string,
name: string,
color?: string | null,
icon?: string | null
icon?: string | null,
sortMode?: SortMode | null
) => Promise<Chapter>;
/** Name, colour, or both — one write for one decision. */
editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise<void>;
/** Everything a chapter is, in one write — it is decided in one dialog. */
editChapter: (
id: string,
updates: { name?: string; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
@@ -283,7 +288,6 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const all = notebook.separators || [];
return {
notes,
all,
mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId),
others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId),
/* Chapters are rows in this list too, and only in the notebook itself — a
@@ -297,7 +301,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
(
notebookId: string,
chapterId: string | null,
all: Separator[],
/** This list's own lines as they were, for telling whether anything moved. */
mine: Separator[],
others: Separator[],
next: Arrangement
) => {
@@ -320,15 +325,15 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* that share an `updatedAt` second — so "last changed" quietly came out in
* a different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
* Compared **in order**, and only this list's own lines. Sorting the
* comparison was how a line refused to go under another one: two lines in
* the same gap carry the same position, so swapping them changes nothing
* but their sequence — and a sorted comparison cannot see a sequence. The
* move was computed correctly, drawn correctly for a frame, and then
* thrown away as "no change". */
const fingerprint = (seps: Separator[]) =>
seps
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
if (fingerprint(merged) !== fingerprint(all)) {
seps.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.color ?? ""}|${sep.style ?? ""}`).join("\n");
if (fingerprint(next.separators) !== fingerprint(mine)) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
@@ -348,7 +353,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters);
if (!next) return;
applyArrangement(notebookId, chapterId, list.all, list.others, next);
applyArrangement(notebookId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -372,7 +377,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = placeOnTop(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -394,7 +399,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
if (!list) return;
const next = withoutRow(list.notes, list.mine, list.chapters, note.id);
if (!next) return;
applyArrangement(note.folderId, chapterId, list.all, list.others, next);
applyArrangement(note.folderId, chapterId, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
@@ -479,8 +484,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
*/
const createChapter = useCallback(
async (notebookId: string, name: string, color: string | null = null, icon: string | null = null) => {
const chapter = await sync.createChapter(notebookId, name, color, icon);
async (
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null,
sortMode: SortMode | null = null
) => {
const chapter = await sync.createChapter(notebookId, name, color, icon, sortMode);
setChapters((p) => [...p, chapter]);
return chapter;
},
@@ -492,7 +503,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* They used to be two calls because they came from two places — a dialog and
* a submenu — and renaming a chapter while recolouring it then meant two
* requests for one decision. */
const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => {
const editChapter = useCallback(async (id: string, updates: Parameters<NoteData["editChapter"]>[1]) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c)));
await sync.updateChapter(id, updates);
}, []);
@@ -528,7 +539,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
)
: // An empty one only leaves a gap to close.
withoutRow(list.notes, list.mine, list.chapters, id);
if (next) applyArrangement(notebookId, null, list.all, list.others, next);
if (next) applyArrangement(notebookId, null, list.mine, list.others, next);
},
[applyArrangement, listOf]
);
+32 -8
View File
@@ -16,6 +16,7 @@ import type {
NoteVersionSummary,
Notebook,
QueueEntry,
SortMode,
StrokeDoc,
SyncStatus,
User,
@@ -86,6 +87,7 @@ interface ServerChapter {
color?: string | null;
icon?: string | null;
sort_order?: number;
sort_mode?: string | null;
}
interface ServerNote {
@@ -547,11 +549,25 @@ type QueuedAction =
| { action: "deleteNotebook"; data: { id: string } }
| {
action: "createChapter";
data: { localId: string; notebookId: string; name: string; color?: string | null; icon?: string | null };
data: {
localId: string;
notebookId: string;
name: string;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| {
action: "updateChapter";
data: { id: string; name?: string; sortOrder?: number; color?: string | null; icon?: string | null };
data: {
id: string;
name?: string;
sortOrder?: number;
color?: string | null;
icon?: string | null;
sortMode?: SortMode | null;
};
}
| { action: "deleteChapter"; data: { id: string } }
| {
@@ -667,6 +683,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
name: d.name,
color: d.color ?? null,
icon: d.icon ?? null,
sortMode: d.sortMode ?? null,
});
if (r.id !== d.localId) {
const local = (await db.getAllChapters()).find((c) => c.id === d.localId);
@@ -678,6 +695,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
color: d.color ?? null,
icon: d.icon ?? null,
sortOrder: local?.sortOrder ?? 0,
sortMode: d.sortMode ?? null,
});
// Notes filed into it while it was local, and anything still queued
// behind this create — both address a name only this device knew.
@@ -694,6 +712,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
sortOrder: a.data.sortOrder,
color: a.data.color,
icon: a.data.icon,
sortMode: a.data.sortMode,
});
break;
case "deleteChapter":
@@ -829,6 +848,10 @@ export async function fetchChapters(): Promise<Chapter[]> {
color: c.color ?? null,
icon: c.icon ?? null,
sortOrder: c.sort_order || 0,
/* Read here or it is lost: every sync replaces the local mirror with what
this function returns, and a chapter that came back without its order
would drop to the hand-made one seconds after being set. */
sortMode: (c.sort_mode as SortMode | null) ?? null,
}));
const local = await db.getAllChapters();
// Anything made offline is kept: its create is still in the queue, and the
@@ -843,13 +866,14 @@ export async function createChapter(
notebookId: string,
name: string,
color: string | null = null,
icon: string | null = null
icon: string | null = null,
sortMode: SortMode | null = null
): Promise<Chapter> {
const sortOrder = (await db.getAllChapters()).filter((c) => c.notebookId === notebookId).length;
if (_online && api.token) {
try {
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder };
const r = await api.post<{ id: string }>("/chapters", { notebookId, name, color, icon, sortMode });
const chapter: Chapter = { id: r.id, notebookId, name, color, icon, sortOrder, sortMode };
await db.putChapter(chapter);
notifyData("chapter", chapter);
return chapter;
@@ -858,9 +882,9 @@ export async function createChapter(
}
}
const lid = "ch_" + Math.random().toString(36).slice(2, 10);
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, _pendingCreate: true };
const chapter: Chapter = { id: lid, notebookId, name, color, icon, sortOrder, sortMode, _pendingCreate: true };
await db.putChapter(chapter);
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon } });
await enqueue({ action: "createChapter", data: { localId: lid, notebookId, name, color, icon, sortMode } });
notifyData("chapter", chapter);
notifyStatus("pending");
return chapter;
@@ -868,7 +892,7 @@ export async function createChapter(
export async function updateChapter(
id: string,
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null }
updates: { name?: string; sortOrder?: number; color?: string | null; icon?: string | null; sortMode?: SortMode | null }
): Promise<void> {
const chapter = (await db.getAllChapters()).find((c) => c.id === id);
if (!chapter) return;
+4
View File
@@ -126,6 +126,10 @@ export interface Chapter {
/** An emoji standing in for the default folder mark. */
icon?: string | null;
sortOrder: number;
/* How its own notes are ordered — the notebook's setting governs the
notebook's own list, not what is inside a chapter. Null is the hand-made
order, which is what a chapter is in until it is told otherwise. */
sortMode?: SortMode | null;
/** Set while a chapter created offline has not reached the server yet. */
_pendingCreate?: boolean;
}
+34 -1
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { CHAPTER_COLORS } from "./constants.jsx";
import { SORT_LABELS, SORT_MODES } from "../features/notes/noteSort";
import { IconChooser } from "./IconChooser.jsx";
import { I } from "./icons.jsx";
import { useBackdropClose } from "./backdrop";
@@ -23,6 +24,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const [name, setName] = useState(chapter?.name || "");
const [icon, setIcon] = useState(chapter?.icon ?? null);
const [colour, setColour] = useState(chapter?.color ?? null);
const [sortMode, setSortMode] = useState(chapter?.sortMode || "manual");
const inputRef = useRef(null);
const backdrop = useBackdropClose(onClose);
@@ -46,7 +48,7 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
const ready = !!name.trim();
const save = () => {
if (!ready) return;
onSave({ name: name.trim(), icon, color: colour });
onSave({ name: name.trim(), icon, color: colour, sortMode });
onClose();
};
@@ -144,6 +146,37 @@ export function ChapterDialog({ chapter, onSave, onClose, dark }) {
}}
/>
{/* A chapter orders its own notes.
The notebook's setting governs the notebook's own list; a chapter is
a place someone made and filled, so a reference chapter can stay
alphabetical while the notebook around it stays as it was arranged.
Lines and handles belong to the hand-made order and are only offered
there — in "newest first" the notes would slide past a line. */}
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Order</div>
<select
value={sortMode}
aria-label="Chapter order"
data-chapter-sort
onChange={(e) => setSortMode(e.target.value)}
style={{
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: T.field,
color: T.text,
fontSize: 13,
fontFamily: "inherit",
outline: "none",
}}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{SORT_LABELS[mode]}
</option>
))}
</select>
<div style={{ fontSize: 11.5, color: T.muted, fontWeight: 600, margin: "16px 0 8px" }}>Colour</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: 7 }}>
{[{ label: "None", value: null }, ...CHAPTER_COLORS].map((c) => {