The picture fills the account button now — twenty pixels of a face is a smudge — and light or dark moved out of the sidebar corner into Account → Profile, next to the typeface and how a chapter opens, which is the kind of thing it is. Moving it made it worth keeping: it was pure screen state, so every reload landed on dark whatever had been chosen. It is a setting on the account now, like the typeface, with a copy in the browser so the page does not start in one theme and turn to the other once the account arrives. The check I first wrote for the sidebar losing its switch could not fail. It counts the buttons in that corner instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
411 lines
17 KiB
JavaScript
411 lines
17 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` });
|
|
|
|
/* ── the sheet is the right size from the first frame ──
|
|
* It was not. The stage starts at the canonical page, 1600px tall, and the
|
|
* measurement that pulls it back to the pane ran an ordinary effect — a frame
|
|
* too late. Every note opened by flashing a long narrow sheet and then
|
|
* righting itself. Sampled every frame, because one frame is all it was. */
|
|
const settled = await p.locator("[data-page-sheet]").first().evaluate((el) => el.getBoundingClientRect().height);
|
|
await p.evaluate(() => {
|
|
window.__tall = 0;
|
|
const tick = () => {
|
|
const el = document.querySelector("[data-page-sheet]");
|
|
if (el) window.__tall = Math.max(window.__tall, el.getBoundingClientRect().height);
|
|
window.__raf = requestAnimationFrame(tick);
|
|
};
|
|
tick();
|
|
});
|
|
await p.locator("button").filter({ hasText: "Ziel" }).first().click();
|
|
await p.waitForSelector(".ProseMirror", { timeout: 15000 });
|
|
await p.waitForTimeout(1500);
|
|
const tallest = await p.evaluate(() => {
|
|
cancelAnimationFrame(window.__raf);
|
|
return window.__tall;
|
|
});
|
|
check(
|
|
tallest <= settled * 1.15,
|
|
"switching notes never draws the sheet at the wrong size, not even for a frame",
|
|
`settled ${Math.round(settled)}, tallest ${Math.round(tallest)}`
|
|
);
|
|
await reopenNote(p, "Quelle");
|
|
/* ── 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)
|
|
);
|
|
|
|
/* ── a quote that holds more than a sentence ──
|
|
* Markdown lets a quote hold whole blocks, and so does the editor. What it did
|
|
* not do was give them any room: inside a quote nothing had a margin, so a
|
|
* heading, a list and a closing line stood jammed together while the items of
|
|
* the list were further apart than the blocks around them. */
|
|
await createNote(p);
|
|
await setTitle(p, "Zitat");
|
|
await p.locator(".ProseMirror").click();
|
|
await p.keyboard.type("> ### Quartalszahlen");
|
|
await p.keyboard.press("Enter");
|
|
await p.keyboard.type("- Umsatz");
|
|
await p.keyboard.press("Enter");
|
|
await p.keyboard.type("Gewinn");
|
|
await p.keyboard.press("Enter");
|
|
await p.keyboard.press("Enter");
|
|
await p.keyboard.type("Alles nach Plan.");
|
|
await p.waitForTimeout(1500);
|
|
|
|
const quoted = await p.evaluate(() =>
|
|
[...(document.querySelector(".ProseMirror blockquote")?.children || [])].map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return { tag: el.tagName, top: r.top, bottom: r.bottom };
|
|
})
|
|
);
|
|
check(
|
|
quoted.map((c) => c.tag).join(",") === "H3,UL,P",
|
|
"a quote holds a heading, a list and a paragraph",
|
|
quoted.map((c) => c.tag).join(",")
|
|
);
|
|
const gaps = quoted.slice(1).map((c, i) => Math.round(c.top - quoted[i].bottom));
|
|
check(gaps.every((g) => g >= 6), "with room between them, as outside a quote", gaps.join(" | "));
|
|
|
|
/* And the same divide when it leaves as Markdown: written on consecutive
|
|
lines, every other reader folds that closing line into the last bullet. */
|
|
const quoteNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Zitat");
|
|
const quoteDoc = (await api(p, "GET", `/notes/${quoteNote.id}`)).body.content;
|
|
check(/blockquote/.test(quoteDoc), "and it is stored as a quote");
|
|
|
|
/* ── 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"
|
|
);
|
|
|
|
/* ── light or dark is a setting, and it belongs to the account ──
|
|
* It used to be a sun in the corner of the sidebar: reached for by accident,
|
|
* forgotten by the next device, and gone on every reload. */
|
|
const shade = () =>
|
|
p.evaluate(() => getComputedStyle(document.querySelector("#root > div") || document.body).backgroundColor);
|
|
const wasDark = await shade();
|
|
|
|
await p.getByTitle("Account").click();
|
|
await p.waitForTimeout(900);
|
|
check(
|
|
(await p.locator("[data-theme-option]").count()) === 2,
|
|
"the account panel offers light and dark"
|
|
);
|
|
await p.locator('[data-theme-option="light"]').click();
|
|
await p.waitForTimeout(1500);
|
|
await p.keyboard.press("Escape");
|
|
await p.waitForTimeout(900);
|
|
check((await shade()) !== wasDark, "choosing one repaints the app", `${wasDark} → ${await shade()}`);
|
|
|
|
const themed = await api(p, "GET", "/auth/me");
|
|
check(
|
|
themed.body?.preferences?.theme === "light",
|
|
"and it is stored on the account, not in this browser",
|
|
JSON.stringify(themed.body?.preferences)
|
|
);
|
|
|
|
await p.reload({ waitUntil: "networkidle" });
|
|
await p.waitForTimeout(3000);
|
|
check((await shade()) !== wasDark, "and it is still there after a reload", await shade());
|
|
|
|
/* The sun in the sidebar is gone with it — two switches for one setting is one
|
|
too many, and that corner is for places to go. Counted where it stood: the
|
|
account button's own row, which used to hold two. */
|
|
const cornerButtons = await p
|
|
.locator('[title="Account"]')
|
|
.evaluate((el) => el.parentElement.querySelectorAll("button").length);
|
|
check(cornerButtons === 1, "and the sidebar corner holds nothing but the account", `${cornerButtons} buttons`);
|
|
|
|
} catch (e) {
|
|
check(false, "suite ran to the end", e.message);
|
|
} finally {
|
|
await session?.cleanup();
|
|
const code = report();
|
|
await browser.close();
|
|
process.exit(code);
|
|
}
|