A chapter is a list too, and deleting stops moving things

Deleting a note moved everything else. A row leaving is the same problem as
a row arriving, in reverse: a line's position counts the rows above it, so a
note deleted above a line left the line counting rows that were no longer
there, and everything under it appeared to jump up past it. withoutRow
renumbers the list a row leaves, and every place one leaves says so -- the
bin, a move, a chapter being removed. Arrivals land at the top: an old
position belongs to an arrangement that has moved on.

A line could not be dragged under another line. Two lines in the same gap
carry the same position, and the comparator claimed "a separator comes
first" without asking what the other one was -- both directions at once for
that case. A comparator that contradicts itself has no defined result, so
the sort returned them in whichever order it happened to walk.

A chapter is always in the user's own order and can be arranged like any
other list: its notes wear handles under the same switch as the list beside
them and are dragged among themselves, strips at both ends included. The two
lists keep apart 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 sort menu is gone inside a
chapter: the mode belongs to the notebook, and a menu there would offer a
choice that cannot be honoured.

The chapters suite no longer assumes the first chapter row is the one that
matters. A chapter is a row in the arrangement and can be anywhere in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 12:37:02 +02:00
1 parent 312c4e190e
commit f01522aa6f
10 files changed
+528 -85

No files matched your search

+60 -23
View File
@@ -216,36 +216,67 @@ function ChapterRow({ T, chapter, open, onOpen, onContextMenu }) {
*/
export function ChapterNotes({
T,
chapterId,
notes,
separators,
reorderable,
moving = false,
onReorder,
activeNote,
onSeparatorContextMenu,
onSelectNote,
onNoteContextMenu,
compact = false,
}) {
// Lines only mean something where the order is the user's own, exactly as in
// the notebook's list — in "newest first" the notes slide past them.
// A chapter is always in the order it was filled in, so its lines always mean
// something and its rows can always be dragged.
const entries = reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry);
/* Its own drag, scoped to this chapter.
*
* Unfolded under its row, these rows sit *inside* a row of the notebook's
* list, so "the nearest row above the pointer" cannot say which of the two
* lists is being dragged in. The scope says it. */
const dnd = useReorderDrag(
!!moving && !!onReorder,
onReorder || (() => {}),
undefined,
`chapter:${chapterId ?? ""}`
);
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 }}>
{dnd.overId === START_ID && <DropLine T={T} />}
</div>
)}
{entries.map((entry) =>
entry.kind === "separator" ? (
<SeparatorRow
<div
key={entry.id}
T={T}
separator={entry.separator}
onContextMenu={onSeparatorContextMenu}
grip={null}
/>
{...(moving ? dnd.rowProps(entry.id) : {})}
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
>
<SeparatorRow
T={T}
separator={entry.separator}
onContextMenu={onSeparatorContextMenu}
grip={moving ? <Grip T={T} {...dnd.gripProps} /> : null}
/>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
) : (
<button
<div
key={entry.id}
{...(moving ? dnd.rowProps(entry.id) : {})}
style={{ opacity: dnd.draggingId === entry.id ? 0.45 : 1 }}
>
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
{moving && <Grip T={T} {...dnd.gripProps} />}
<button
data-chapter-note={entry.id}
onClick={() => onSelectNote(entry.id)}
onContextMenu={(e) => onNoteContextMenu(e, entry.note)}
@@ -291,9 +322,17 @@ export function ChapterNotes({
{entry.note.title}
</span>
</button>
</div>
{moving && dnd.overId === entry.id && <DropLine T={T} />}
</div>
)
)}
{/* No button to add one here. A line is drawn in the notebook and dragged
{moving && entries.length > 0 && (
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 34 }}>
{dnd.overId === END_ID && <DropLine T={T} />}
</div>
)}
{/* No button to add a line here. One is drawn in the notebook and dragged
into the chapter, the same way a note is put in one — a second way to
make the same thing, in a second place, is a second thing to keep
straight. */}
@@ -518,6 +557,10 @@ export function NoteList({
query,
onQueryChange,
reorderable,
/** Whether rearranging is switched on. Held above this list, because a chapter
shown beside it is a second list under the same switch. */
reordering,
onToggleReorder,
separators,
onAddSeparator,
onSeparatorContextMenu,
@@ -546,16 +589,7 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
/* Rearranging is a mode, switched on for as long as it is being done.
*
* It ends by itself when the list is left: coming back to a notebook to find
* every row wearing a handle, because it was rearranged last week, is not
* where anybody left off. */
const [reordering, setReordering] = useState(false);
// Off again on the way to another list, and whenever a hand-made arrangement
// stops being in effect at all — an order the app chose cannot be dragged.
useEffect(() => setReordering(false), [listKey, reorderable]);
const moving = !!reorderable && reordering;
const moving = !!reorderable && !!reordering;
/* Reordering by pointer rather than by the HTML5 drag API, which mobile
browsers never fire from touch — on a phone the arrangement could not be
@@ -644,10 +678,13 @@ export function NoteList({
</h2>
</div>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{reorderable && (
<ReorderToggle T={T} on={reordering} onToggle={() => setReordering((v) => !v)} />
{reorderable && <ReorderToggle T={T} on={!!reordering} onToggle={onToggleReorder} />}
{/* Not inside a chapter: the mode belongs to the notebook, and a
chapter is always in the order it was filled in. A menu there
would offer a choice that cannot be honoured. */}
{!isTrashView && !openChapter && (
<SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />
)}
{!isTrashView && <SortMenu T={T} mode={sortMode} modes={sortModes} onChange={onSortModeChange} />}
{!isTrashView && (
<AddMenu
T={T}
@@ -11,6 +11,7 @@ import {
sortModesFor,
sortNotes,
withNewSeparator,
withoutRow,
} from "./noteSort";
import type { NoteMeta } from "../../types";
@@ -277,3 +278,71 @@ describe("placeOnTop", () => {
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
});
});
describe("two lines in the same gap", () => {
/* A separator's position counts the rows above it, so two lines with nothing
between them carry the same number. Which of them comes first is then only
the order they are written in — and the comparator used to claim *both*
directions for that case, so a line dragged under another sprang back. */
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 })];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
it("keeps the order they were written in", () => {
const two = [
{ id: "s1", label: "Erst", order: 1 },
{ id: "s2", label: "Dann", order: 1 },
];
expect(kinds(mergeEntries(notes, two))).toEqual(["n:a", "s:s1", "s:s2", "n:b"]);
expect(kinds(mergeEntries(notes, [two[1]!, two[0]!]))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
});
it("can be swapped by dragging one under the other", () => {
const two = [
{ id: "s1", label: "Erst", order: 1 },
{ id: "s2", label: "Dann", order: 1 },
];
const next = reindexEntries(notes, two, "s1", "s2")!;
expect(next.separators.map((s) => s.id)).toEqual(["s2", "s1"]);
expect(kinds(mergeEntries(notes, next.separators))).toEqual(["n:a", "s:s2", "s:s1", "n:b"]);
});
});
describe("withoutRow", () => {
/* a │ b │ ─── │ c: deleting a note above the line used to leave the line
counting rows that were no longer there, so everything under it appeared to
jump up past it. */
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
it("closes the gap and takes the lines down with it", () => {
const next = withoutRow(notes, separators, [], "a")!;
expect(next.noteIds).toEqual(["b", "c"]);
expect(next.separators[0]!.order).toBe(1);
const left = [note("b", { sortOrder: 0 }), note("c", { sortOrder: 1 })];
expect(kinds(mergeEntries(left, next.separators))).toEqual(["n:b", "s:s1", "n:c"]);
});
it("leaves a line that was already above the gap where it was", () => {
const next = withoutRow(notes, [{ id: "s1", label: "", order: 1 }], [], "c")!;
expect(next.separators[0]!.order).toBe(1);
});
it("reports nothing to do for a row that is not in the list", () => {
expect(withoutRow(notes, separators, [], "ghost")).toBeNull();
});
});
describe("placeOnTop with several notes", () => {
/* Removing a chapter frees its notes into the notebook. They arrive together,
at the top, in the order they had inside it. */
const notes = [note("x", { sortOrder: 0 }), note("y", { sortOrder: 1 })];
const freed = [note("p"), note("q")];
const separators = [{ id: "s1", label: "", order: 1 }];
it("puts them all in front, in the order given", () => {
const next = placeOnTop([...freed, ...notes], separators, [], ["p", "q"])!;
expect(next.noteIds).toEqual(["p", "q", "x", "y"]);
expect(next.separators[0]!.order).toBe(3);
});
});
+50 -12
View File
@@ -161,11 +161,20 @@ export function mergeEntries(
separator,
}));
return [...rowEntries, ...separatorEntries].sort(
return [...rowEntries, ...separatorEntries].sort((a, b) => {
if (a.order !== b.order) return a.order - b.order;
/* Two lines in the same gap keep the order they were written in.
*
* This used to say "a separator comes first" without asking what the other
* one was — so for two separators it claimed *both* directions at once. A
* comparator that contradicts itself has no defined result, and the sort
* returned them in whichever order it happened to walk: a line dragged under
* another sprang back above it, every time. */
if (a.kind === "separator" && b.kind === "separator") return 0;
// 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)
);
return a.kind === "separator" ? -1 : 1;
});
}
/** The result of a drag: what to write back for each kind of row. */
@@ -256,22 +265,51 @@ export function placeOnTop(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
id: string
ids: string | string[]
): Arrangement | null {
const note = notes.find((n) => n.id === id);
if (!note) return null;
/* Built from the list *without* the new note, then the note put in front.
const wanted = Array.isArray(ids) ? ids : [ids];
const arriving = wanted
.map((id) => notes.find((n) => n.id === id))
.filter((n): n is NoteMeta => !!n);
if (arriving.length === 0) return null;
/* Built from the list *without* the new notes, then those put in front.
*
* Merging it in first would be measuring the room after the furniture moved:
* the new row already counts as a row, so every line reads as sitting one
* place further down than it was drawn, and renumbering would write that
* Merging them in first would be measuring the room after the furniture
* moved: a new row already counts as a row, so every line reads as sitting
* one place further down than it was drawn, and renumbering would write that
* misreading back as the truth. */
const held = new Set(arriving.map((n) => n.id));
const entries = mergeEntries(
notes.filter((n) => n.id !== id),
notes.filter((n) => !held.has(n.id)),
separators,
chapters
);
entries.unshift({ kind: "note", id, order: 0, note });
entries.unshift(...arriving.map((note) => ({ kind: "note" as const, id: note.id, order: 0, note })));
return toArrangement(entries);
}
/**
* The whole list renumbered with one row taken out of it.
*
* For a note being thrown away or moved elsewhere. A row leaving is the same
* problem as a row arriving, in reverse: a separator's position counts the rows
* above it, so a note deleted above a line left that line one row too *low*, and
* everything below the line appeared to jump up past it. Nothing here moves
* except what is leaving.
*
* `notes` must still contain the row — it is the list as it is drawn, which is
* what the lines were counted against.
*/
export function withoutRow(
notes: NoteMeta[],
separators: Separator[],
chapters: Chapter[],
id: string
): Arrangement | null {
const entries = mergeEntries(notes, separators, chapters);
const at = entries.findIndex((e) => e.id === id);
if (at < 0) return null;
entries.splice(at, 1);
return toArrangement(entries);
}