One direction to drop in, and a new note that stays on top

Every new note pulled the arrangement apart. A note was made with no
position and left to the tie-break, which put it near the top by age --
which is not the same thing. A separator's position counts the rows above
it, so a row appearing without a renumber left every line one row too
high. placeOnTop builds the order from the list *without* the new note and
puts it in front: measuring the room after the furniture has moved would
write the misreading back as the truth.

A drop now lands the row under the one it was released on, and only there.
Dropping above a row and dropping below the row before it are the same move
described twice, so every gap could be aimed at from two sides. The strip
above the first row is the one place a drop under something cannot reach.

A chapter could only be dropped into, never dragged past: taking the whole
row as a place quietly took reordering away. The bottom edge of its header
is the gap now, the rest of the row is still the chapter.

A link showed the sheet of paper whatever it pointed at. The @ menu dropped
the mark on the way into the link, and the refresh that keeps a link current
ran once, while the editor was still empty -- content is poured in afterwards
without an update event, so nothing ever looked again.

The mark in the title bar and the box in a task list were each centred on a
line box rather than on the letters in it, which is not the same as being on
one line. The mark shares the title's baseline; the box is centred on the
capitals and sized in em.

Move to... is divided under headings that are deliberately not shaped like
entries, and each row carries the mark its chapter or notebook wears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 11:45:16 +02:00
1 parent 2ffa357dfc
commit a9391cdcf2
15 files changed
+724 -189

No files matched your search

+58 -5
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import {
END_ID,
START_ID,
canReorder,
mergeEntries,
placeOnTop,
reindex,
reindexEntries,
sortModesFor,
@@ -89,8 +91,10 @@ describe("reindex", () => {
note("d", { sortOrder: 3 }),
];
it("moves a note onto the position of the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
/* Under, always under. Both directions meant every gap could be aimed at from
two sides, which is what made a drag hard to predict. */
it("moves a note under the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "b", "d", "c"]);
});
it("works upwards as well as downwards", () => {
@@ -198,18 +202,25 @@ describe("reindexEntries", () => {
const notes = [note("a", { sortOrder: 0 }), note("b", { sortOrder: 1 }), note("c", { sortOrder: 2 })];
const separators = [{ id: "s1", label: "Teil 2", order: 1 }];
it("moves a note across a separator and keeps the separator where it was", () => {
it("moves a note across a separator and leaves the line above the same note", () => {
// a │ ─── │ b │ c, and c is dropped under a. The line still sits above b;
// its number counts the rows before it, and there is one more of those now.
const next = reindexEntries(notes, separators, "c", "a")!;
expect(next.noteIds).toEqual(["c", "a", "b"]);
expect(next.noteIds).toEqual(["a", "c", "b"]);
expect(next.separators[0]!.order).toBe(2);
});
it("moves a separator itself", () => {
const next = reindexEntries(notes, separators, "s1", "a")!;
const next = reindexEntries(notes, separators, "s1", START_ID)!;
expect(next.separators[0]!.order).toBe(0);
expect(next.noteIds).toEqual(["a", "b", "c"]);
});
it("sends a row to the top when dropped on the strip above the first one", () => {
// The one place a drop under a row cannot reach.
expect(reindexEntries(notes, [], "c", START_ID)!.noteIds).toEqual(["c", "a", "b"]);
});
it("sends a row to the end when dropped below the last one", () => {
expect(reindexEntries(notes, [], "a", END_ID)!.noteIds).toEqual(["b", "c", "a"]);
});
@@ -224,3 +235,45 @@ describe("reindexEntries", () => {
expect(next.separators).toEqual([{ id: "s1", label: "x", order: 0 }]);
});
});
describe("placeOnTop", () => {
/* A notebook someone has arranged: two notes with a line between them, and a
chapter above both. The numbers are what a drag left behind. */
const notes = [note("a", { sortOrder: 1 }), note("b", { sortOrder: 2 })];
const chapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 0 }] as never;
const separators = [{ id: "s1", label: "Teil 2", order: 2 }];
const kinds = (list: ReturnType<typeof mergeEntries>) => list.map((e) => `${e.kind[0]}:${e.id}`);
const fresh = [note("neu", { sortOrder: 0 }), ...notes];
const arranged = placeOnTop(fresh, separators, chapters, "neu")!;
it("puts the new note above everything, lines and chapters included", () => {
expect(arranged.notes[0]).toEqual({ id: "neu", order: 0 });
expect(arranged.chapters).toEqual([{ id: "ch", order: 1 }]);
});
it("leaves every other row in the order it was in", () => {
expect(arranged.noteIds).toEqual(["neu", "a", "b"]);
});
/* The whole point. A separator's number counts the rows above it, so a row
appearing at the top without a renumber leaves every line one row too high
— which is how a notebook came apart a little more with each new note. */
it("moves the lines down with the rows they sat above", () => {
expect(arranged.separators[0]!.order).toBe(3);
// Drawn again from exactly what was written back.
const after = [note("neu", { sortOrder: 0 }), note("a", { sortOrder: 2 }), note("b", { sortOrder: 3 })];
const movedChapters = [{ id: "ch", notebookId: "nb", name: "Kapitel", sortOrder: 1 }] as never;
expect(kinds(mergeEntries(after, arranged.separators, movedChapters))).toEqual([
"n:neu",
"c:ch",
"n:a",
"s:s1",
"n:b",
]);
});
it("reports nothing to do for a note that is not in the list", () => {
expect(placeOnTop(notes, separators, chapters, "ghost")).toBeNull();
});
});