Let a margin remark become a note, and lead to it ever after

Some remarks turn into a page of their own. "Notiz anlegen" on a margin card
makes one, titles it from what was written, and goes there; the pin keeps its id
so the passage leads to that note afterwards. The note is created with a link
back to the document, so the connection reads both ways and the document lists it
among its backlinks — a note born from a passage should not be a note with
nothing on it saying which passage. An existing note can be linked instead, and a
link can be let go of without touching the note. Both are offered on the phone
sheet too.

Two older bugs stood in the way and are fixed:

`openLinkTarget` finds a note's notebook by looking it up in the notes list, and
a note created in the same tick is not in the copy that callback closed over — so
jumping to a fresh note went nowhere at all. Creating one now navigates by the
notebook it already knows.

A drawing save waits 700ms for the next stroke, and leaving the document inside
that window dropped it. That is precisely what turning a remark into a note does,
so the link would have been lost on the way out. The viewer flushes a pending
write when it unmounts, which also covers drawing and navigating away quickly.

215 unit tests, 229 browser checks across eleven suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-10 16:40:38 +02:00
1 parent 011ba82a94
commit 502aecfc92
10 files changed
+541 -12

No files matched your search

+75 -1
View File
@@ -7,6 +7,7 @@
import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
import {
api,
captureDownloads,
check,
clearDownloads,
@@ -119,6 +120,7 @@ try {
await p.waitForTimeout(4000);
check((await p.locator("[data-pdf-page]").count()) > 0, "the document is shown after importing");
const pdfNoteId = (await api(p, "GET", "/notes")).body[0].id;
check(
(await p.locator("[data-pdf-position]").innerText()).includes("/ 2"),
"both pages are counted",
@@ -631,6 +633,73 @@ try {
/* ── a remark that turns into a note ── */
await p.locator("[data-pdf-pin-mode]").click();
await p.waitForTimeout(400);
const pageL = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.click(pageL.x + pageL.width * 0.35, pageL.y + pageL.height * 0.2);
await p.waitForTimeout(800);
await p.getByLabel("Pinned note").fill("Zebrastreifen nachschlagen");
await p.waitForTimeout(400);
const notesBefore = (await api(p, "GET", "/notes")).body.length;
await p.locator("[data-pin-make-note]").click();
await p.waitForTimeout(4000);
const notesAfter = (await api(p, "GET", "/notes")).body;
check(notesAfter.length === notesBefore + 1, "a remark can become a note of its own", `${notesBefore} → ${notesAfter.length}`);
const made = notesAfter.find((n) => n.title === "Zebrastreifen nachschlagen");
check(!!made, "titled from what was written in the margin");
check(
(await p.locator(".ProseMirror").count()) > 0,
"and the app goes straight there rather than leaving it to be found"
);
/* The link has to read in both directions, or a note born from a passage has
nothing on it saying which passage. */
const body = (await api(p, "GET", `/notes/${made.id}`)).body.content;
check(body.includes(pdfNoteId), "the new note carries a link back to the document", body.slice(0, 120));
const back = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`);
check(
Array.isArray(back.body) && back.body.some((b) => b.id === made.id),
"so the document lists it among its backlinks",
JSON.stringify(back.body).slice(0, 120)
);
/* Back to the document: the pin now leads to the note. */
await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click();
await p.waitForTimeout(4000);
const goto = p.locator(`[data-pin-goto="${made.id}"]`);
check((await goto.count()) === 1, "and the margin card links to it");
check(
(await goto.innerText()).includes("Zebrastreifen"),
"showing the note's current title",
await goto.innerText()
);
await goto.click();
await p.waitForTimeout(2500);
check(
(await p.getByLabel("Note title").inputValue()) === "Zebrastreifen nachschlagen",
"clicking it goes to the note",
await p.getByLabel("Note title").inputValue()
);
/* Back once more, and let go of the link — the note must survive that. */
await p.locator("[data-note-row]").filter({ hasText: "e2e-test" }).first().click();
await p.waitForTimeout(4000);
await p.locator(`[data-pin-goto="${made.id}"]`).locator("xpath=ancestor::*[@data-pin-card][1]").click();
await p.waitForTimeout(700);
await p.locator("[data-pin-unlink]").click();
await p.waitForTimeout(2500);
check((await p.locator(`[data-pin-goto="${made.id}"]`).count()) === 0, "the link can be let go of");
check(
(await api(p, "GET", "/notes")).body.some((n) => n.id === made.id),
"and the note itself is still there"
);
/* ── on a phone there is no margin to write in ── */
// A page narrow enough to read leaves no white space beside it, so taking a
// column for notes would make the document itself unreadable. The note comes
@@ -656,7 +725,12 @@ try {
// backdrop up, which no real phone ever sees. Dismissed here so the rest of
// the section is testing the document and not an overlay.
if ((await p.locator("[data-pdf-page]").count()) === 0) {
await p.evaluate(() => document.querySelector("[data-note-row]")?.click());
// By title: the notebook now holds the note that grew out of a remark too,
// and the first row is not necessarily the document.
await p.evaluate(() => {
const rows = [...document.querySelectorAll("[data-note-row]")];
(rows.find((r) => r.textContent.includes("e2e-test")) || rows[0])?.click();
});
await p.waitForTimeout(4500);
}
await p.mouse.click(410, 800);