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

+51 -6
View File
@@ -279,15 +279,22 @@ try {
/* Held and dropped in two steps, with a wait between: the highlight is React
state, and asking for it in the same breath as the move that caused it reads
the DOM before it has been drawn. */
const holdOver = (dragId, chapterId) =>
/* `where` picks the part of the row held over: a chapter is a place in the
body of its header and a neighbour along the bottom edge of it, which is
how a row can still be put in the gap under a chapter. */
const holdOver = (dragId, chapterId, where = "body") =>
p.evaluate(
([from, to]) => {
([from, to, part]) => {
const row = document.querySelector(`[data-reorder-id="${from}"]`);
const grip = row?.querySelector("[data-reorder-grip]");
const target = document.querySelector(`[data-reorder-id="${to}"]`);
if (!grip || !target) return `missing ${!!grip} ${!!target}`;
const box = target.getBoundingClientRect();
window.__drop = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
const head = target.querySelector("[data-reorder-head]") || target;
const box = head.getBoundingClientRect();
window.__drop = {
x: box.x + box.width / 2,
y: part === "edge" ? box.bottom - 2 : box.y + box.height / 2,
};
const fire = (el, type, x, y) =>
el.dispatchEvent(
new PointerEvent(type, {
@@ -305,7 +312,7 @@ try {
fire(window, "pointermove", window.__drop.x, window.__drop.y);
return "held";
},
[dragId, chapterId]
[dragId, chapterId, where]
);
const letGo = () =>
@@ -357,8 +364,15 @@ try {
const shownChapters = await p
.locator("[data-chapter-row]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-chapter-row")));
// The two strips at the ends of the list are drop targets, not rows: they
// carry a reorder id and nothing else, least of all a grip to drag by.
const noteId = rowOrder.find(
(id) => id && !id.startsWith("sep-") && id !== "__end__" && !shownChapters.includes(id)
(id) =>
id &&
!id.startsWith("sep-") &&
id !== "__end__" &&
id !== "__start__" &&
!shownChapters.includes(id)
);
check(
!!noteId && !!shownChapters[0],
@@ -395,6 +409,37 @@ try {
check(false, "a separator was there to drag", "none found");
}
/* ── a chapter is a neighbour too ──
* Taking the whole row as "into" quietly took reordering away: a chapter could
* not be moved among its neighbours, and nothing could be put in the gap under
* one. The bottom edge of the header is that gap, and asks for a line rather
* than lighting the chapter up. */
const looseLeft = (
await p.locator("[data-reorder-id]").evaluateAll((els) => els.map((e) => e.dataset.reorderId))
).find(
(id) =>
id &&
!id.startsWith("sep-") &&
id !== "__end__" &&
id !== "__start__" &&
!shownChapters.includes(id)
);
if (looseLeft) {
const held = await holdOver(looseLeft, shownChapters[0], "edge");
await p.waitForTimeout(400);
const litAtEdge = await p.locator("[data-chapter-drop]").count();
const lineAtEdge = await p.locator("[data-drop-line]").count();
await letGo();
await p.waitForTimeout(2000);
check(
held === "held" && litAtEdge === 0 && lineAtEdge === 1,
"held over the bottom edge of a chapter, it offers a line instead of taking the row in",
JSON.stringify({ held, litAtEdge, lineAtEdge })
);
} else {
check(false, "a loose note was left to try the edge with", "none");
}
/* ── a dialog does not close because a selection ended outside it ──
* Select a word in the field, drag a little past the edge, let go: the click
* lands on the backdrop — the nearest ancestor of press and release — and the
+73 -4
View File
@@ -127,13 +127,23 @@ try {
};
const rows = p.locator("[data-note-row]");
/* A drop lands *under* the row it was released on, and only there — both
directions meant every gap could be aimed at from two sides. */
await drag(rows.nth(2), rows.nth(0));
const under = await titles();
check(
under[1] === beforeDrag[2] && under[0] === beforeDrag[0],
"dropping a note on a row puts it under that row",
`${beforeDrag.join(",")} → ${under.join(",")}`
);
// Which leaves the very top out of reach, so it has a strip of its own.
await drag(rows.nth(2), p.locator("[data-drop-start]"));
const arranged = await titles();
check(
arranged[0] === beforeDrag[2],
"dragging a note to the top puts it there",
`${beforeDrag.join(",")} → ${arranged.join(",")}`
arranged[0] === under[2],
"and the strip above the first row puts it at the very top",
`${under.join(",")} → ${arranged.join(",")}`
);
/* The search box is simply there, with nothing to open first. A magnifying
@@ -293,6 +303,47 @@ try {
await p.waitForTimeout(800);
check((await p.locator("[data-separator]").count()) === 1, "and back on the way in");
/* ── a new note goes on top, and takes nothing with it ──
*
* It used to be given no position at all and left to the tie-break, which put
* it near the top and moved everything else: a line's position counts the rows
* above it, so a row appearing without a renumber left every line one row too
* high, and the arrangement came apart a little more with each new note. */
const layout = () =>
p
.locator("[data-note-row], [data-separator]")
.evaluateAll((els) =>
els.map((el) =>
el.hasAttribute("data-separator") ? `── ${el.innerText.trim()}` : el.innerText.split("\n")[0].trim()
)
);
const beforeNew = await layout();
await createNote(p);
await p.waitForTimeout(2500);
const afterNew = await layout();
check(
afterNew[0] === "Untitled Note",
"a new note is made at the very top, above the lines as well",
afterNew.join(" | ")
);
check(
afterNew.slice(1).join(" | ") === beforeNew.join(" | "),
"and every other row stayed exactly where it was",
`${beforeNew.join(" | ")} → ${afterNew.join(" | ")}`
);
// And it survives the trip to the server: the positions were written, not
// only drawn.
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
await p.waitForTimeout(1800);
check(
(await layout()).join(" | ") === afterNew.join(" | "),
"and the arrangement comes back the same after a reload",
(await layout()).join(" | ")
);
/* ── the choices reach the server, and the screen ──
* A notebook divided into three parts is easier to read at a glance when the
* lines differ; grey-on-grey they all look like the same line repeated. */
@@ -338,7 +389,11 @@ try {
(await p.locator("[data-confirm-dialog]").count()) === 0,
"and the question is gone with it, not left standing over the app"
);
check((await titles()).length === 3, "with every note still there", (await titles()).join(", "));
check(
(await titles()).join(",") === afterNew.filter((row) => !row.startsWith("──")).join(","),
"with every note still there",
(await titles()).join(", ")
);
/* ── exporting a note and importing it back ── */
await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click();
@@ -640,6 +695,20 @@ try {
"and the notebooks it offers are on the screen, not past its edge",
JSON.stringify(await sub.boundingBox())
);
/* It holds two different kinds of place, so it says which is which. The
headings are deliberately not shaped like entries: one built like a menu row
is a button that does nothing when pressed. */
const headings = await sub.locator("[data-menu-heading]").allInnerTexts();
check(
headings.some((h) => /other notebooks/i.test(h)),
"the list is divided under headings rather than run together",
headings.join(" / ")
);
check(
(await sub.locator("[data-menu-heading]").evaluateAll((els) => els.every((el) => el.tagName !== "BUTTON"))),
"and a heading is not a button"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
await p.setViewportSize({ width: 1400, height: 950 });