Two things wrong with the paper. "Start writing..." was printed at the foot of every page that had a break after it. The placeholder extension styles `[contenteditable]:empty::before`, and the spacer holding a gap open is exactly that: empty, and not editable. It inherited a placeholder meant for an empty note. And the column was too narrow for the sheet it now sits on — about 73 characters, which left 117px of blank paper down each edge and made the writing look lost. Widened to about 80, a little short of where the eye stops reliably finding the start of the next line, which leaves roughly 8% of the sheet as margin: a document's margin rather than a picture frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
13 KiB
JavaScript
306 lines
13 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 placeholder extension styles `[contenteditable]:empty::before`, and a
|
|
* spacer is exactly that. "Start writing..." was printed at the foot of every
|
|
* page that had a break after it. */
|
|
check(
|
|
await p.evaluate(() =>
|
|
[...document.querySelectorAll(".ne-page-spacer")].every(
|
|
(el) => getComputedStyle(el, "::before").content === "none"
|
|
)
|
|
),
|
|
"and the gap stays blank — no placeholder printed at the foot of the page"
|
|
);
|
|
|
|
/* 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);
|
|
}
|