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