/* 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); }