Files
InkNote/e2e/editor.mjs
T
marcandClaude Opus 5 97a3764d92 Write on paper: a sheet per page, with a gap between them
The writing surface was a column of text in an empty pane. A sheet was
drawn behind it, but only once you started drawing — so writing looked
like text floating in the middle of nothing, and the space either side of
the column read as a void rather than as a margin.

Now every page is a sheet of its own, centred, with a gap after it: the
same way the PDF reader draws its pages, because a page in a note and a
page in a document should read as the same kind of thing.

The gap is the part that takes work. The browser knows nothing about the
sheets and lays the text out as one flow, putting a line straight across
the gap and cutting it in half. So editor/pagination.js measures the flow
and pushes any block that would straddle a gap onto the next page, with a
spacer of exactly the right height in front of it. A paragraph moves down
entire, which is what a page break means on paper. A block taller than a
page is left to run over, since splitting one would mean laying out its
insides ourselves.

The spacers are decorations, not content: they never enter the document,
never reach the server and never appear in an export. Two checks hold
that down — one asks whether any block sits in a gap, the other reads the
stored body back and looks for them there.

Two things that are easy to get wrong. The spacer's height is not the
distance the text moves: margins that used to collapse into each other no
longer do with something between them, so the layout gains that much for
free and the spacer is made exactly that much shorter. And heights change
with no transaction to announce it — a webfont landing, an image
arriving, the window resizing — which a ResizeObserver on the editor
element catches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:17:28 +02:00

295 lines
12 KiB
JavaScript

/* Editor: tables, task lists, links between notes, and whether any of it
* survives a reload — the place where a stale server response used to eat
* freshly typed content. */
import {
OUT,
api,
check,
createNote,
createNotebook,
insertTable,
launch,
reopenNote,
report,
setTitle,
typeInEditor,
withFreshUser,
} from "./lib.mjs";
const browser = await launch();
let session;
try {
session = await withFreshUser(browser, "editor");
const p = session.page;
await createNotebook(p, "Editor Book");
await createNote(p);
await typeInEditor(p, "Grundtext");
/* ── tables ── */
await insertTable(p);
check((await p.locator(".ProseMirror table").count()) > 0, "table inserted");
check((await p.locator(".ProseMirror table th").count()) >= 3, "table has a header row");
/* ── task list ── */
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.getByTitle("Task list").click();
await p.waitForTimeout(400);
await p.keyboard.type("Milch kaufen");
await p.waitForTimeout(1200);
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list inserted");
await p.locator('.ProseMirror input[type="checkbox"]').first().click();
await p.waitForTimeout(1500);
await p.screenshot({ path: `${OUT}/editor-content.png` });
/* ── right-click menu on the table ── */
const cellCount = () => p.locator(".ProseMirror table td, .ProseMirror table th").count();
const before = await cellCount();
await p.locator(".ProseMirror table td").first().click({ button: "right" });
await p.waitForTimeout(600);
check((await p.getByText("Insert row below").count()) > 0, "right-click in a table opens the table menu");
check((await p.getByText("Delete table").count()) > 0, "and offers deleting the table");
await p.screenshot({ path: `${OUT}/editor-table-menu.png` });
await p.getByText("Insert row below").click();
await p.waitForTimeout(1000);
check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`);
const withRow = await cellCount();
await p.locator(".ProseMirror table td").first().click({ button: "right" });
await p.waitForTimeout(600);
await p.getByText("Delete row").click();
await p.waitForTimeout(1000);
check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`);
// Outside a table the browser's own menu must stay available.
await p.locator(".ProseMirror p").first().click({ button: "right" });
await p.waitForTimeout(500);
check(
(await p.getByText("Insert row below").count()) === 0,
"right-click outside a table shows no table menu"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
/* ── a second note to link to ── */
await setTitle(p, "Quelle");
await createNote(p);
await setTitle(p, "Ziel");
await typeInEditor(p, "Ich bin das Ziel.");
await p.locator("button").filter({ hasText: "Quelle" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(1200);
/* ── note link via the @ menu ── */
// Control+End lands inside the task list, where a plain Enter would only make
// another task. A second Enter on the empty item leaves the list, so the link
// goes into a paragraph of its own and the checked task above stays untouched.
await p.locator(".ProseMirror").click();
await p.keyboard.press("Control+End");
await p.keyboard.press("Enter");
await p.keyboard.press("Enter");
await p.waitForTimeout(300);
await p.keyboard.type("@Ziel");
await p.waitForTimeout(1400);
const menuOpen = (await p.locator("text=Ziel").count()) > 0;
check(menuOpen, "@ opens the link menu");
await p.keyboard.press("Enter");
await p.waitForTimeout(1800);
const linkCount = await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count();
check(linkCount > 0, "note link inserted into the document", `${linkCount} found`);
await p.screenshot({ path: `${OUT}/editor-link.png` });
/* ── the server learned about it ── */
await p.waitForTimeout(1500);
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
await p.waitForTimeout(2000);
const backlink = await p.locator("text=Quelle").count();
check(backlink > 0, "the target shows a backlink to the source");
/* ── everything survives a cold reload ── */
await reopenNote(p, "Quelle");
check((await p.locator(".ProseMirror table").count()) > 0, "table survived a reload");
check(
(await p.locator('.ProseMirror [data-note-link], .ProseMirror .note-link').count()) > 0,
"note link survived a reload"
);
check((await p.locator('.ProseMirror input[type="checkbox"]').count()) > 0, "task list survived a reload");
check(
(await p.locator('.ProseMirror input[type="checkbox"]:checked').count()) === 1,
"the ticked task stayed ticked, and only that one"
);
check(
(await p.locator(".ProseMirror li").filter({ hasText: "Ziel" }).count()) === 0,
"the note link went into a paragraph, not into the task list"
);
check((await p.locator(".ProseMirror").innerText()).includes("Grundtext"), "plain text survived a reload");
await p.screenshot({ path: `${OUT}/editor-after-reload.png` });
/* ── how many pages the writing fills ── */
// Pages are a reading aid, not a change to the document: the note stays one
// body, and Markdown has no page. The height of one is the same 3:4 the canvas
// uses, so "page 2" means the same thing writing or drawing.
await createNote(p);
await setTitle(p, "Langer Text");
check(
(await p.locator("[data-note-position]").innerText()).includes("of 1"),
"a fresh note is one page",
await p.locator("[data-note-position]").innerText()
);
await p.locator(".ProseMirror").click();
// Enough for several pages. With only two, the middle of the window never
// leaves the first one — a window is shorter than a page — so the check would
// be testing arithmetic rather than behaviour.
for (let i = 0; i < 220; i++) {
await p.keyboard.type(`Absatz ${i + 1}: Dichtungen altern durch Wärme.`);
await p.keyboard.press("Enter");
}
await p.waitForTimeout(3500);
const position = () => p.locator("[data-note-position]").innerText();
const scrollTo = async (fraction) => {
await p.evaluate((f) => {
const pm = document.querySelector(".ProseMirror");
let el = pm.parentElement;
while (el && getComputedStyle(el).overflowY !== "auto") el = el.parentElement;
el.scrollTop = (el.scrollHeight - el.clientHeight) * f;
}, fraction);
await p.waitForTimeout(900);
return position();
};
const total = Number((await position()).match(/of (\d+)/)?.[1] || 0);
check(total > 2, "writing past the end of a page makes more of them", await position());
check(
(await p.locator("[data-note-position]").count()) === 1,
"and the count is shown the way the PDF reader shows it"
);
check((await scrollTo(0)).startsWith("Page 1"), "at the top it says page one", await position());
const nearEnd = await scrollTo(0.9);
check(
Number(nearEnd.match(/Page (\d+)/)[1]) > 1,
"scrolling down moves the count on",
nearEnd
);
/* ── the paper ──
* Each page is a sheet of its own with a gap after it, the way the PDF reader
* draws its pages. The gap is the part that has to be got right: the browser
* knows nothing about our sheets and would lay a line of text straight across
* it, so the text flow holds it open — see editor/pagination.js. */
check(
(await p.locator("[data-page-sheet]").count()) === Number((await position()).match(/of (\d+)/)[1]),
"there is a sheet of paper for every page",
`${await p.locator("[data-page-sheet]").count()} sheets, ${await position()}`
);
const straddling = await p.evaluate(() => {
const pm = document.querySelector(".ProseMirror");
const top = pm.getBoundingClientRect().top;
const sheets = [...document.querySelectorAll("[data-page-sheet]")].map((el) => el.getBoundingClientRect());
const gaps = sheets.slice(0, -1).map((r, i) => ({ from: r.bottom - top, to: sheets[i + 1].top - top }));
const bad = [];
for (const el of pm.children) {
if (el.classList.contains("ne-page-spacer")) continue;
const r = el.getBoundingClientRect();
if (r.height === 0) continue;
for (const g of gaps) {
if (r.bottom - top > g.from + 0.5 && r.top - top < g.to - 0.5) bad.push((el.textContent || "").slice(0, 30));
}
}
return bad;
});
check(straddling.length === 0, "and no line of text is cut in half by a gap", straddling.join(" | "));
check(
(await p.locator(".ne-page-spacer").count()) > 0,
"which takes holding the gap open inside the text itself"
);
/* The note is still one document — pages are drawn, not stored. */
const stored = (await api(p, "GET", "/notes")).body[0];
const body = (await api(p, "GET", `/notes/${stored.id}`)).body.content;
check(!/"type"\s*:\s*"page"/.test(body), "no page ever reaches the stored document");
check(!/page-spacer/.test(body), "and neither does anything holding a gap open");
/* ── alignment ── */
// Left is the default and is never written into the document, so an untouched
// paragraph reads as left without carrying an attribute for it.
await createNote(p);
await setTitle(p, "Ausrichtung");
await typeInEditor(p, "Diese Zeile wandert.");
check(
(await p.locator('[data-align="left"]').count()) === 1,
"the toolbar offers alignment"
);
await p.locator('[data-align="center"]').click();
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
"choosing centre centres the paragraph"
);
await reopenNote(p, "Ausrichtung");
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "center",
"and it survives a reload, so it is in the document and not on the screen"
);
const alignedBody = (await api(p, "GET", "/notes")).body;
const alignedNote = alignedBody.find((n) => n.title === "Ausrichtung");
const alignedContent = (await api(p, "GET", `/notes/${alignedNote.id}`)).body.content;
check(/"textAlign"\s*:\s*"center"/.test(alignedContent), "stored on the paragraph itself");
await p.locator('[data-align="left"]').click();
await p.waitForTimeout(1800);
check(
(await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)) === "start",
"and going back to left leaves no alignment behind",
await p.locator(".ProseMirror p").first().evaluate((e) => getComputedStyle(e).textAlign)
);
/* ── the typeface follows the account ── */
await p.getByTitle("Account").click();
await p.waitForTimeout(800);
await p.locator('[data-note-font="mono"]').click();
await p.waitForTimeout(1500);
await p.keyboard.press("Escape");
await p.waitForTimeout(1200);
check(
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
"choosing a typeface changes the note text",
await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)
);
// On the account, not in this browser — so it is there on the next device.
const me = await api(p, "GET", "/auth/me");
check(me.body?.preferences?.noteFont === "mono", "and is stored on the account", JSON.stringify(me.body?.preferences));
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(3000);
// A reload lands on the dashboard, so the notebook has to be opened again.
await p.locator("button").filter({ hasText: "Editor Book" }).first().click();
await p.waitForTimeout(1500);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(2500);
check(
/mono/i.test(await p.locator(".ProseMirror").evaluate((e) => getComputedStyle(e).fontFamily)),
"and survives a reload"
);
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}