Files
marcandClaude Opus 5 4013e959d6 The same page on every screen
The text is laid out on a page of one fixed size and the sheet scaled to
the screen, so a phone breaks it exactly where a desktop does — twenty-one
lines to the page on both. The type size is computed from the page and the
line count rather than chosen.

A PDF's full screen is the browser window, not the screen, and its button
sits beside the zoom. "Recent Notes" is stamped by every write, not only by
a rename. And a PDF that will not open says which of the four reasons it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 13:23:03 +02:00

1040 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Importing and reading a PDF.
*
* The interesting parts are not that a canvas appears: it is that the library
* stays out of the main bundle until a PDF is opened, that the pages come out at
* the right proportions, and that a document read once survives without the
* network — the offline promise is the whole reason the bytes are cached. */
import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
import {
api,
captureDownloads,
check,
clearDownloads,
createNotebook,
downloads,
lastDownloadName,
launch,
openAddMenu,
report,
search,
withFreshUser,
} from "./lib.mjs";
const PDF_PATH = "/tmp/e2e-test.pdf";
/**
* The text inside a PDF's content streams.
*
* pdf-lib flate-compresses what it writes, so searching the raw bytes for a word
* finds nothing whether it is in there or not. Every stream is inflated and the
* results joined; ones that are not compressed (or not inflatable) are taken as
* they are.
*/
function inflatedText(bytes) {
const out = [];
const open = Buffer.from("stream");
const close = Buffer.from("endstream");
let at = 0;
while ((at = bytes.indexOf(open, at)) !== -1) {
// "endstream" contains "stream"; matching it would start the next block
// three bytes into a keyword and inflate garbage.
if (at >= 3 && bytes.subarray(at - 3, at).toString("latin1") === "end") {
at += open.length;
continue;
}
let start = at + open.length;
if (bytes[start] === 0x0d) start++;
if (bytes[start] === 0x0a) start++;
const end = bytes.indexOf(close, start);
if (end === -1) break;
const chunk = bytes.subarray(start, end);
try {
out.push(inflateSync(chunk).toString("latin1"));
} catch {
out.push(chunk.toString("latin1")); // stored, not compressed
}
at = end + close.length;
}
/* Text is written as `<48656C6C6F> Tj` — hex, because the font's encoding is
not necessarily Latin-1. Decoded here so a check can look for a word. */
return out
.join("\n")
.replace(/<([0-9A-Fa-f]{4,})>/g, (whole, hex) =>
hex.length % 2
? whole
: Buffer.from(hex, "hex").toString("latin1")
);
}
/** A two-page PDF built here, so the suite depends on no fixture file. */
function writeTestPdf() {
const objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
null, // stream, filled below
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>",
null,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
const s1 = "BT /F1 24 Tf 72 760 Td (Seite eins Zebrastreifen) Tj ET";
const s2 = "BT /F1 24 Tf 72 520 Td (Seite zwei Dichtung) Tj ET";
objs[3] = `<< /Length ${s1.length} >>\nstream\n${s1}\nendstream`;
objs[5] = `<< /Length ${s2.length} >>\nstream\n${s2}\nendstream`;
let out = "%PDF-1.4\n";
const offsets = [];
objs.forEach((o, i) => {
offsets.push(out.length);
out += `${i + 1} 0 obj\n${o}\nendobj\n`;
});
const xref = out.length;
out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
for (const off of offsets) out += `${String(off).padStart(10, "0")} 00000 n \n`;
out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
writeFileSync(PDF_PATH, out, "latin1");
}
const browser = await launch();
let session;
try {
writeTestPdf();
session = await withFreshUser(browser, "pdf");
const p = session.page;
/* The library must not be in the bundle everyone downloads. */
const scriptsBefore = await p.evaluate(() =>
performance.getEntriesByType("resource").map((r) => r.name)
);
check(
!scriptsBefore.some((u) => /pdf[.-]/i.test(u)),
"pdf.js is not loaded before a PDF is opened",
scriptsBefore.filter((u) => /pdf/i.test(u)).join(", ") || "none"
);
await createNotebook(p, "PDF Buch");
/* ── import ── */
const chooser = p.waitForEvent("filechooser");
await openAddMenu(p);
await p.locator('[data-add-option="Upload PDF"]').click();
await (await chooser).setFiles(PDF_PATH);
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("of 2"),
"both pages are counted",
await p.locator("[data-pdf-position]").innerText()
);
await p.screenshot({ path: "/out/pdf-imported.png" });
/* ── proportions ── */
const boxes = await p.$$eval("[data-pdf-page]", (els) =>
els.map((e) => ({ w: e.offsetWidth, h: e.offsetHeight }))
);
check(boxes.length === 2, "both pages are laid out", JSON.stringify(boxes));
// Page one is A4 portrait (595×842), page two the same turned on its side.
const ratio1 = boxes[0].h / boxes[0].w;
const ratio2 = boxes[1].h / boxes[1].w;
check(Math.abs(ratio1 - 842 / 595) < 0.05, "the portrait page keeps its proportions", ratio1.toFixed(2));
check(Math.abs(ratio2 - 595 / 842) < 0.05, "the landscape page is not stretched to match", ratio2.toFixed(2));
/* ── something was actually painted ── */
const inkedPixels = await p.evaluate(() => {
const c = document.querySelector("[data-pdf-page] canvas");
if (!c) return -1;
const ctx = c.getContext("2d");
const d = ctx.getImageData(0, 0, c.width, Math.min(c.height, 400)).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 200) dark++;
return dark;
});
check(inkedPixels > 50, "the page has actual content drawn on it", `${inkedPixels} dark pixels`);
const loadedAfter = await p.evaluate(() => performance.getEntriesByType("resource").map((r) => r.name));
check(
loadedAfter.some((u) => /pdf.*\.js|pdf\.worker/i.test(u)),
"pdf.js was fetched only once it was needed"
);
/* ── selectable text ── */
const layerText = await p
.locator('[data-pdf-page="1"] .pdf-text-layer')
.innerText()
.catch(() => "");
check(layerText.includes("Zebrastreifen"), "the page text is laid over the picture", layerText.slice(0, 60));
const spanBox = await p.locator('[data-pdf-page="1"] .pdf-text-layer span').first().boundingBox();
const pageBox = await p.locator('[data-pdf-page="1"]').boundingBox();
check(
spanBox && spanBox.width > 4 && spanBox.x > pageBox.x && spanBox.y > pageBox.y,
"and positioned over the page rather than stacked in a corner",
JSON.stringify(spanBox && { x: Math.round(spanBox.x - pageBox.x), y: Math.round(spanBox.y - pageBox.y) })
);
const invisible = await p
.locator('[data-pdf-page="1"] .pdf-text-layer span')
.first()
.evaluate((e) => getComputedStyle(e).color);
check(invisible.includes("rgba(0, 0, 0, 0)"), "the overlaid text is transparent", invisible);
/* Select it the way a reader would: drag across the line. */
await p.mouse.move(spanBox.x + 2, spanBox.y + spanBox.height / 2);
await p.mouse.down();
await p.mouse.move(spanBox.x + spanBox.width - 2, spanBox.y + spanBox.height / 2, { steps: 10 });
await p.mouse.up();
const selected = await p.evaluate(() => String(window.getSelection()));
check(selected.trim().length > 0, "dragging across the line selects text", JSON.stringify(selected.slice(0, 40)));
await p.screenshot({ path: "/out/pdf-selection.png" });
/* ── the document is findable ── */
await p.waitForTimeout(2500); // extraction runs after the first page is shown
await search(p, "Zebrastreifen");
check(
(await p.locator("[data-note-row]").count()) === 1,
"searching a word from inside the PDF finds the note",
`${await p.locator("[data-note-row]").count()} rows`
);
await search(p, "Wortdasnichtvorkommt");
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
await search(p, "");
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(3000);
/* ── drawing on a page ── */
const inkOnPage = (n) =>
p.evaluate((page) => {
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
if (!c) return -1;
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let painted = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) painted++;
return painted;
}, n);
/** Draws a line across the page in fractions of its box, then waits for the save. */
const drawOn = async (pageNo, x1, y1, x2, y2) => {
const box = await p.locator(`[data-pdf-page="${pageNo}"]`).boundingBox();
await p.mouse.move(box.x + box.width * x1, box.y + box.height * y1);
await p.mouse.down();
await p.mouse.move(box.x + box.width * x2, box.y + box.height * y2, { steps: 14 });
await p.mouse.up();
await p.waitForTimeout(1600);
};
check((await inkOnPage(1)) === 0, "no ink before anything is drawn");
await p.locator("[data-pdf-mode]").click();
check((await p.locator('[data-pdf-mode="draw"]').count()) === 1, "drawing mode can be switched on");
const textEvents = await p
.locator('[data-pdf-page="1"] .pdf-text-layer')
.evaluate((e) => getComputedStyle(e).pointerEvents);
check(
textEvents === "none",
"while drawing, the text layer stops taking the pointer so a stroke is not a selection",
textEvents
);
await drawOn(1, 0.2, 0.25, 0.7, 0.3);
const inkedFirst = await inkOnPage(1);
check(inkedFirst > 100, "the stroke appears on the page", `${inkedFirst} painted pixels`);
check((await inkOnPage(2)) === 0, "and only on the page it was drawn on");
await p.screenshot({ path: "/out/pdf-drawn.png" });
/* Where the stroke sits relative to the page, so zoom can be compared against it. */
const inkBounds = (n) =>
p.evaluate((page) => {
const c = document.querySelector(`[data-pdf-ink="${page}"]`);
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let minX = 1e9, maxX = -1, minY = 1e9, maxY = -1;
for (let i = 3, px = 0; i < d.length; i += 4, px++) {
if (d[i] <= 40) continue;
const x = px % c.width, y = (px / c.width) | 0;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
// As fractions of the canvas, so they can be compared across zoom levels.
return { x: minX / c.width, y: minY / c.height, w: (maxX - minX) / c.width, h: (maxY - minY) / c.height };
}, n);
const before = await inkBounds(1);
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2500);
const after = await inkBounds(1);
const drift = Math.max(Math.abs(before.x - after.x), Math.abs(before.y - after.y), Math.abs(before.w - after.w));
check(drift < 0.02, "the stroke stays on the same spot when zoomed", `drift ${drift.toFixed(3)}`);
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1500);
/* ── the document, and nothing else ──
* The app laid out as an overlay over itself: the document takes the whole
* browser window, and the sidebar, the note list and the title bar go. The
* browser's own full screen is deliberately not asked for — this is about
* giving the page to the document, not the screen. */
const workspaceBox = () =>
p.locator("[data-pdf-workspace]").evaluate((el) => {
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
});
const windowBox = () => p.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }));
const normal = await workspaceBox();
check(normal.y > 0 || normal.x > 0, "in the ordinary layout the document shares the screen", JSON.stringify(normal));
await p.locator("[data-pdf-fullscreen]").click();
await p.waitForTimeout(1200);
const filled = await workspaceBox();
const win = await windowBox();
check(
filled.x === 0 && filled.y === 0 && filled.w >= win.w - 1 && filled.h >= win.h - 1,
"full screen leaves the document the whole window",
`${JSON.stringify(filled)} vs ${JSON.stringify(win)}`
);
check(
(await p.locator("[data-pdf-position]").count()) === 1,
"with its toolbar still there — the page number, the zoom, the way out"
);
await p.keyboard.press("Escape");
await p.waitForTimeout(1000);
const back = await workspaceBox();
check(back.y === normal.y && back.x === normal.x, "and Escape gives the app back", JSON.stringify(back));
await p.locator("[data-pdf-fullscreen]").click();
await p.waitForTimeout(1000);
await p.locator("[data-pdf-fullscreen]").click();
await p.waitForTimeout(1000);
check(
(await workspaceBox()).y === normal.y,
"and so does pressing the button again — the way out is visible, not only known"
);
/* ── Ctrl and the wheel ──
* The claim is not only "the number changes". It is that the browser is kept
* out of it — an unprevented Ctrl+wheel zooms the whole application, toolbar
* and all — and that the spot under the pointer is the spot that stays put,
* which is the whole difference between a viewer that feels steady and one
* that jumps to the top of the page at every step. */
const zoomNow = () => p.locator("[data-pdf-zoom]").evaluate((el) => Number(el.dataset.pdfZoom));
const pageRect = () =>
p.locator('[data-pdf-page="1"]').evaluate((el) => {
const r = el.getBoundingClientRect();
return { top: r.top, height: r.height };
});
const firstPage = await p.locator('[data-pdf-page="1"]').boundingBox();
// A point well inside the page, and low enough that holding it still means
// scrolling rather than doing nothing.
const at = { x: firstPage.x + firstPage.width * 0.5, y: firstPage.y + firstPage.height * 0.6 };
const beforeRect = await pageRect();
const fraction = (at.y - beforeRect.top) / beforeRect.height;
const zoomBefore = await zoomNow();
const wheeled = await p.evaluate(
([x, y]) => {
const el = document.elementFromPoint(x, y);
let prevented = null;
const spy = (e) => (prevented = e.defaultPrevented);
// Added after the viewer's own listener, so it runs after it and can see
// whether the default was called off.
document.addEventListener("wheel", spy, { passive: true });
el.dispatchEvent(
new WheelEvent("wheel", { deltaY: -120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
);
document.removeEventListener("wheel", spy);
return prevented;
},
[at.x, at.y]
);
await p.waitForTimeout(2000);
check((await zoomNow()) > zoomBefore, "Ctrl and the wheel zoom in", `${zoomBefore} → ${await zoomNow()}`);
check(wheeled === true, "and the browser is stopped from zooming itself instead", `defaultPrevented ${wheeled}`);
const afterRect = await pageRect();
const held = afterRect.top + fraction * afterRect.height;
check(
Math.abs(held - at.y) < 12,
"and the spot under the pointer stays under the pointer",
`${Math.round(at.y)} → ${Math.round(held)}`
);
await p.evaluate(
([x, y]) => {
document
.elementFromPoint(x, y)
.dispatchEvent(
new WheelEvent("wheel", { deltaY: 120, ctrlKey: true, bubbles: true, cancelable: true, clientX: x, clientY: y })
);
},
[at.x, at.y]
);
await p.waitForTimeout(1800);
check((await zoomNow()) === zoomBefore, "and the other way out again", `${await zoomNow()}`);
/* The buttons hold something else still: the page being read. "Show me this
page larger" should not answer by moving to another one. */
await p.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded();
await p.waitForTimeout(1200);
const readingBefore = await p.locator("[data-pdf-position]").innerText();
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2200);
check(
(await p.locator("[data-pdf-position]").innerText()) === readingBefore,
"zooming from the buttons keeps the page being read",
`${readingBefore} → ${await p.locator("[data-pdf-position]").innerText()}`
);
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1500);
/* ── hiding it ── */
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(600);
check((await inkOnPage(1)) >= 0, "hiding the drawing leaves the document readable");
const hiddenOpacity = await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity);
check(hiddenOpacity === "0", "the ink layer is hidden, not erased", hiddenOpacity);
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(600);
/* ── it survives ── */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4500);
const afterReload = await inkOnPage(1);
check(afterReload > 100, "the stroke survived a reload", `${afterReload} painted pixels`);
/* ── it must not be undone by the background poller ── */
await p.waitForTimeout(9000);
check((await inkOnPage(1)) > 100, "and is still there after the sync poller has run");
/* ── notes beside the document ── */
check((await p.locator("[data-pdf-notes]").count()) === 0, "the notes panel starts folded away");
await p.locator("[data-pdf-toggle-notes]").click();
await p.waitForTimeout(1200);
check((await p.locator("[data-pdf-notes]").count()) === 1, "it opens beside the document");
check((await p.locator("[data-pdf-page]").count()) > 0, "and the document is still there next to it");
const noteText = "Wichtig auf Seite eins: Randbemerkung.";
await p.locator("[data-pdf-notes] .ProseMirror").click();
await p.keyboard.type(noteText);
await p.waitForTimeout(2200);
await p.locator("[data-pdf-toggle-notes]").click();
await p.waitForTimeout(600);
check((await p.locator("[data-pdf-notes]").count()) === 0, "and folds away again without losing anything");
/* Written notes belong to the note, so they survive like any other text. */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4000);
await p.locator("[data-pdf-toggle-notes]").click();
await p.waitForTimeout(1500);
check(
(await p.locator("[data-pdf-notes] .ProseMirror").innerText()).includes("Randbemerkung"),
"the notes survived a reload"
);
await p.screenshot({ path: "/out/pdf-with-notes.png" });
/* And they are searchable, alongside the document's own text. */
await search(p, "Randbemerkung");
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
await search(p, "");
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(3000);
/* ── exporting the document ── */
await captureDownloads(p);
const originalBytes = readFileSync(PDF_PATH);
const openExport = async () => {
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(1200);
};
await openExport();
check(
(await p.getByText("What should come out?").count()) > 0,
"a PDF note is asked what should come out, not offered note formats"
);
await p.screenshot({ path: "/out/pdf-export-wizard.png" });
/* The untouched original. */
await clearDownloads(p);
await p.getByRole("button", { name: /The original PDF/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(900);
let files = await downloads(p);
const plain = Buffer.from(files[0].base64, "base64");
check(files[0].type === "application/pdf", "the original comes out as a PDF", files[0].type);
check(
plain.equals(originalBytes),
"and is byte-identical to the file that was imported",
`${plain.length} vs ${originalBytes.length} bytes`
);
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* The same document with the strokes written in. */
await openExport();
await clearDownloads(p);
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500);
files = await downloads(p);
const stamped = Buffer.from(files[0].base64, "base64");
check(stamped.subarray(0, 5).toString() === "%PDF-", "the annotated file is a PDF", stamped.subarray(0, 8).toString());
check(stamped.length > originalBytes.length, "and carries more than the original did", `${stamped.length} vs ${originalBytes.length}`);
check(!stamped.equals(originalBytes), "so it is not simply a copy");
check(/annotated/.test(await lastDownloadName(p)), "named so the two cannot be confused", await lastDownloadName(p));
/* The stored file must still be the original — drawing on it changes nothing
on disk, or a clean copy could never be had again. */
const storedAgain = await p.evaluate(async () => {
const t = localStorage.getItem("inknote_token");
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
const id = notes[0].pdf_attachment_id;
const r = await fetch("/api/attachments/" + id, { headers: { Authorization: "Bearer " + t } });
const buf = new Uint8Array(await r.arrayBuffer());
return Array.from(buf.slice(0, 16));
});
check(
Buffer.from(storedAgain).equals(originalBytes.subarray(0, 16)),
"the stored file was never modified by drawing on it"
);
/* Bytes added is not the same as ink visible. Render both files and compare
what is actually on page one. */
const inkVisible = await p.evaluate(async ([stampedB64, originalB64]) => {
const toBytes = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
// The app has already loaded this chunk; importing the same URL reuses it.
const chunk = performance
.getEntriesByType("resource")
.map((r) => r.name)
.find((n) => /\/assets\/pdf-[A-Za-z0-9_-]+\.js$/.test(n));
if (!chunk) throw new Error("pdf.js chunk not found among loaded resources");
const pdfjs = await import(/* @vite-ignore */ chunk);
const countDark = async (bytes) => {
const doc = await pdfjs.getDocument({ data: bytes }).promise;
const page = await doc.getPage(1);
const vp = page.getViewport({ scale: 1 });
const c = document.createElement("canvas");
c.width = Math.round(vp.width);
c.height = Math.round(vp.height);
const ctx = c.getContext("2d");
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, c.width, c.height);
await page.render({ canvasContext: ctx, viewport: vp, canvas: c }).promise;
const d = ctx.getImageData(0, 0, c.width, c.height).data;
let dark = 0;
for (let i = 0; i < d.length; i += 4) if (d[i] < 200 || d[i + 1] < 200 || d[i + 2] < 200) dark++;
return dark;
};
return { stamped: await countDark(toBytes(stampedB64)), original: await countDark(toBytes(originalB64)) };
}, [files[0].base64, originalBytes.toString("base64")]);
check(
inkVisible.stamped > inkVisible.original * 1.2,
"the strokes are actually visible when the exported file is rendered",
`${inkVisible.original} → ${inkVisible.stamped} dark pixels`
);
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── it stays readable offline ── */
// A reload lands on the dashboard, which has no note list; the notebook has
// to be opened first before a row exists to click.
const reopen = async () => {
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4000);
};
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await reopen();
check((await p.locator("[data-pdf-page]").count()) > 0, "the document reopens after a reload");
await p.context().setOffline(true);
await p.reload({ waitUntil: "domcontentloaded" });
await p.waitForTimeout(3000);
await reopen();
check(
(await p.locator("[data-pdf-page]").count()) > 0,
"and still opens with the network switched off"
);
await p.screenshot({ path: "/out/pdf-offline.png" });
await p.context().setOffline(false);
/* ── notes pinned to a passage ── */
// The panel beside the document is for notes about the document; this is for
// a note about the paragraph you were reading.
await p.locator("[data-pdf-hide-ink]").click().catch(() => {}); // ink out of the way
await p.waitForTimeout(400);
await p.locator("[data-pdf-hide-ink]").click().catch(() => {});
await p.waitForTimeout(400);
check(
(await p.locator("[data-pdf-pin-mode]").count()) === 1,
"the viewer offers pinning a note to a spot"
);
await p.locator("[data-pdf-pin-mode]").click();
await p.waitForTimeout(400);
check(
(await p.locator("[data-pdf-pin-mode]").getAttribute("data-pdf-pin-mode")) === "on",
"and says it is waiting for a spot"
);
const page1 = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.click(page1.x + page1.width * 0.4, page1.y + page1.height * 0.3);
await p.waitForTimeout(800);
check((await p.locator("[data-pin]").count()) === 1, "clicking the page leaves a marker on the spot");
check(
(await p.locator("[data-pdf-margin]").count()) > 0,
"and the note itself belongs in the margin beside the page, not on it"
);
const field = p.getByLabel("Pinned note");
check(await field.isVisible(), "which opens straight into writing");
/* The card has to be level with the passage — that is the whole point of a
margin note, and the reason it is not simply a list under the document. */
const levels = await p.evaluate(() => {
const marker = document.querySelector("[data-pin]").getBoundingClientRect();
const card = document.querySelector("[data-pin-card]").getBoundingClientRect();
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
return { gap: Math.abs(marker.top - card.top), pageRight: page.right, cardLeft: card.left };
});
check(levels.gap < 40, "the card sits level with its marker", `${Math.round(levels.gap)}px apart`);
check(levels.cardLeft >= levels.pageRight - 2, "and beside the page rather than over it");
await field.fill("Drehmoment beachten");
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
/* It has to be on the passage, not near it — so the position is checked
against the page rather than merely being non-zero. */
const placed = await p.evaluate(() => {
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
});
check(
Math.abs(placed.x - 0.4) < 0.03 && Math.abs(placed.y - 0.3) < 0.03,
"the pin sits where the page was clicked",
`${placed.x.toFixed(2)} / ${placed.y.toFixed(2)}`
);
/* Stored in PDF points, so zooming must not move it off its passage — the same
property the strokes have. */
await p.getByTitle("Zoom in").click();
await p.waitForTimeout(2000);
const zoomed = await p.evaluate(() => {
const pin = document.querySelector("[data-pin]").getBoundingClientRect();
const page = document.querySelector('[data-pdf-page="1"]').getBoundingClientRect();
return { x: (pin.x + pin.width / 2 - page.x) / page.width, y: (pin.y + pin.height / 2 - page.y) / page.height };
});
check(
Math.abs(zoomed.x - placed.x) < 0.02 && Math.abs(zoomed.y - placed.y) < 0.02,
"and stays there when the document is zoomed",
`${zoomed.x.toFixed(2)} / ${zoomed.y.toFixed(2)}`
);
await p.getByTitle("Zoom out").click();
await p.waitForTimeout(1200);
/* Searchable: it lives in the page overlay, not in the note body, so nothing
else would ever index it. */
await search(p, "Drehmoment");
check(
(await p.locator("[data-note-row]").count()) === 1,
"a pinned note can be searched for",
String(await p.locator("[data-note-row]").count())
);
await search(p, "");
/* It belongs to the note, so it has to come back from the server. */
await p.reload({ waitUntil: "networkidle" });
await p.waitForTimeout(2500);
await p.locator("button").filter({ hasText: "PDF Buch" }).first().click();
await p.waitForTimeout(1200);
await p.locator("[data-note-row]").first().click();
await p.waitForTimeout(4000);
check((await p.locator("[data-pin]").count()) === 1, "the pin survives a reload");
check(
(await p.locator("[data-pin-card]").innerText()).includes("Drehmoment"),
"with the text showing in the margin without being opened first",
await p.locator("[data-pin-card]").innerText()
);
await p.locator("[data-pin]").click();
await p.waitForTimeout(600);
check(
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten",
"and the same text when opened for editing"
);
/* Clearing a page's ink must not take its pinned notes: same page, different
thing. */
await p.keyboard.press("Escape");
await p.waitForTimeout(400);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(500);
await p.getByTitle("Clear page").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-pin]").count()) === 1, "emptying the page's ink leaves the pinned note");
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(500);
await p.locator("[data-pin]").click();
await p.waitForTimeout(600);
await p.locator("[data-pin-remove]").click();
await p.waitForTimeout(2000);
check((await p.locator("[data-pin]").count()) === 0, "a pinned note can be removed again");
/* An export that dropped the reader's own words would lose work as surely as
one that dropped the drawings. Written back in before the pin is removed. */
await p.locator("[data-pdf-pin-mode]").click();
await p.waitForTimeout(400);
const page1b = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.click(page1b.x + page1b.width * 0.75, page1b.y + page1b.height * 0.7);
await p.waitForTimeout(700);
await p.getByLabel("Pinned note").fill("Randbemerkung Zebrastreifen");
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
/* The server keeps its own judgement of "is this overlay empty". It counted
strokes only, so an overlay holding just pinned notes was stored as null and
every note in it was lost. */
const overlay = await p.evaluate(async () => {
const t = localStorage.getItem("inknote_token");
const notes = await (await fetch("/api/notes", { headers: { Authorization: "Bearer " + t } })).json();
const d = await (await fetch("/api/notes/" + notes[0].id, { headers: { Authorization: "Bearer " + t } })).json();
return JSON.stringify(d.drawingStrokes ?? null);
});
check(
overlay.includes("Randbemerkung"),
"the server stores an overlay that holds only pinned notes",
overlay.slice(0, 120)
);
await captureDownloads(p);
await clearDownloads(p);
await p.getByTitle("Export", { exact: true }).click();
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
await p.waitForTimeout(800);
await p.getByRole("button", { name: /PDF with your annotations/ }).click();
await p.waitForSelector("[data-export-done]", { timeout: 30000 });
await p.waitForTimeout(1500);
const withPins = Buffer.from((await downloads(p))[0].base64, "base64");
check(withPins.subarray(0, 5).toString() === "%PDF-", "the export still produces a PDF");
check(
inflatedText(withPins).includes("Randbemerkung"),
"and the pinned note's own words are written into it",
`${withPins.length} bytes`
);
await p.keyboard.press("Escape");
await p.waitForTimeout(800);
/* ── linking a margin note from the notes beside the document ── */
// A remark in the margin is short by design. When there is more to say about
// that passage it belongs in the notes — and it has to be possible to say
// *which* passage.
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.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
await p.locator("[data-pdf-toggle-notes]").click();
await p.waitForTimeout(1200);
const panel = p.locator("[data-pdf-notes] .ProseMirror");
await panel.click();
await p.keyboard.type("Dazu ausführlicher: ");
await p.keyboard.type("@");
await p.waitForTimeout(1200);
const suggestions = await p.locator("body").innerText();
check(
/Zebrastreifen nachschlagen/.test(suggestions),
"the margin notes are offered when linking from the notes",
suggestions.slice(0, 160).replace(/\n/g, " ")
);
await p.keyboard.press("Enter");
await p.waitForTimeout(2500);
const linkEl = panel.locator("[data-note-link]").first();
check((await linkEl.count()) === 1, "one can be inserted as a link");
check(
(await linkEl.getAttribute("data-kind")) === "pin",
"recorded as a link to a spot, not to a note",
await linkEl.getAttribute("data-kind")
);
check(
(await linkEl.innerText()).includes("p. 1"),
"labelled with the page it points at",
await linkEl.innerText()
);
/* The link must not reach the backlink table: its target is a marker in this
note's own overlay, and no note has that id. */
const backAfter = await api(p, "GET", `/notes/${pdfNoteId}/backlinks`);
check(
Array.isArray(backAfter.body) && backAfter.body.length === 0,
"and is not reported as a link to a note",
JSON.stringify(backAfter.body).slice(0, 100)
);
/* Following it goes to the passage. */
await panel.locator("[data-note-link]").first().click();
await p.waitForTimeout(2500);
check(
(await p.getByLabel("Pinned note").inputValue()) === "Zebrastreifen nachschlagen",
"following the link opens the margin note it points at",
await p.getByLabel("Pinned note").inputValue()
);
// With the notes panel open there is no room for a margin column beside the
// page, so the remark opens in the sheet instead. Closing the panel gives the
// column back — and the same remark is still the open one.
await p.locator("[data-pdf-toggle-notes]").click();
await p.waitForTimeout(1500);
const openCard = p.locator("[data-pin-card]").filter({ has: p.getByLabel("Pinned note") });
check((await openCard.count()) === 1, "and it is the card that is open in the margin");
await p.keyboard.press("Escape");
await p.waitForTimeout(600);
/* ── the two visibility switches, together at the end of the bar ── */
// Drawings and margin notes are two halves of one question — how much of your
// own work do you want to see — and they used to sit at opposite ends of the
// toolbar, each beside the tool that made them.
// Each switch appears only when there is something of its kind to hide, so a
// stroke goes back on the page first — the pair is the point here.
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(600);
const inkPage = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.move(inkPage.x + 60, inkPage.y + 300);
await p.mouse.down();
await p.mouse.move(inkPage.x + 160, inkPage.y + 330, { steps: 8 });
await p.mouse.up();
await p.waitForTimeout(2000);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
const barOrder = await p.evaluate(() => {
const bar = document.querySelector("[data-pdf-mode]")?.parentElement;
if (!bar) return null;
const at = (sel) => {
const el = document.querySelector(sel);
return el ? el.getBoundingClientRect().left : null;
};
return {
draw: at("[data-pdf-mode]"),
pin: at("[data-pdf-pin-mode]"),
ink: at("[data-pdf-hide-ink]"),
pins: at("[data-pdf-show-pins]"),
barRight: bar.getBoundingClientRect().right,
};
});
check(
barOrder.ink > barOrder.pin && barOrder.pins > barOrder.ink,
"both switches sit past the tools, side by side",
JSON.stringify(barOrder)
);
check(
barOrder.barRight - barOrder.pins < 160,
"at the right-hand end of the bar",
`${Math.round(barOrder.barRight - barOrder.pins)}px from the edge`
);
/* The drawing tools belong to the button that turns them on. */
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
const toolAt = await p.evaluate(() => ({
draw: document.querySelector("[data-pdf-mode]").getBoundingClientRect().right,
firstTool: document.querySelector('[data-pdf-tool="pen"]').getBoundingClientRect().left,
pin: document.querySelector("[data-pdf-pin-mode]").getBoundingClientRect().left,
}));
check(
toolAt.firstTool > toolAt.draw && toolAt.firstTool < toolAt.pin,
"and the drawing tools appear right beside Zeichnen, not past everything else",
JSON.stringify(toolAt)
);
await p.locator("[data-pdf-mode]").click();
await p.waitForTimeout(700);
/* Each switch hides its own thing and nothing else. They shared a condition
once, so hiding the drawing took the markers and their column with it —
one switch turning off both of the things the pair exists to separate. */
const drawingShows = async () =>
(await p.locator('[data-pdf-ink="1"]').evaluate((c) => getComputedStyle(c).opacity)) !== "0";
const marginNotesShow = async () => (await p.locator("[data-pin]").count()) > 0;
check((await drawingShows()) && (await marginNotesShow()), "both are showing to begin with");
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(900);
check(!(await drawingShows()), "hiding the drawing hides the drawing");
check(await marginNotesShow(), "and leaves the margin notes alone");
check(
(await p.locator("[data-pdf-margin]").count()) > 0,
"including their column",
String(await p.locator("[data-pdf-margin]").count())
);
await p.locator("[data-pdf-hide-ink]").click();
await p.waitForTimeout(900);
await p.locator("[data-pdf-show-pins]").click();
await p.waitForTimeout(900);
check(!(await marginNotesShow()), "hiding the margin notes hides the margin notes");
check(await drawingShows(), "and leaves the drawing alone");
await p.locator("[data-pdf-show-pins]").click();
await p.waitForTimeout(900);
check((await p.locator("[data-pdf-show-pins]").count()) === 1, "the margin notes can be hidden");
await p.locator("[data-pdf-show-pins]").click();
await p.waitForTimeout(1200);
check((await p.locator("[data-pin]").count()) === 0, "hiding takes the markers off the page");
check((await p.locator("[data-pdf-margin]").count()) === 0, "and the column with them");
const pageWide = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
await p.locator("[data-pdf-show-pins]").click();
await p.waitForTimeout(1200);
check((await p.locator("[data-pin]").count()) > 0, "showing brings them back");
check(
(await p.locator('[data-pdf-page="2"]').boundingBox()).width < pageWide,
"and the page gives the column its width again"
);
/* ── 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
// up from the bottom instead — over the page, but only while it is open.
await p.locator("[data-pdf-pin-mode]").click();
await p.waitForTimeout(400);
const pageM = await p.locator('[data-pdf-page="1"]').boundingBox();
await p.mouse.click(pageM.x + pageM.width * 0.5, pageM.y + pageM.height * 0.4);
await p.waitForTimeout(800);
await p.getByLabel("Pinned note").fill("Am Rand notiert");
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(2500);
const wideMargins = await p.locator("[data-pdf-margin]").count();
check(wideMargins > 0, "on a wide screen the note is in the margin", String(wideMargins));
const pageWithMargin = (await p.locator('[data-pdf-page="1"]').boundingBox()).width;
await p.setViewportSize({ width: 420, height: 820 });
await p.waitForTimeout(2000);
// Below the phone breakpoint the list and the editor take turns, so the note
// has to be opened again to be looking at the document at all.
// Shrinking a desktop window past the breakpoint leaves the sidebar and its
// 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) {
// 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);
await p.waitForTimeout(800);
check(
(await p.locator("[data-pdf-margin]").count()) === 0,
"on a narrow one the margin is gone",
String(await p.locator("[data-pdf-margin]").count())
);
check((await p.locator("[data-pin]").count()) > 0, "but the markers are still on their spots");
// The widest page should fill what the screen has, rather than share it with
// a column. (Page 1 is portrait in a document whose widest page is landscape,
// so it is legitimately narrower — page 2 is the one that sets the scale.)
void pageWithMargin;
const widest = (await p.locator('[data-pdf-page="2"]').boundingBox()).width;
check(
widest > 420 - 60,
"and the page gets the width the column was taking",
`${Math.round(widest)}px of 420`
);
/* Reserving a column made the viewer wider, which made there be room for the
column — the page ended up at 254px inside a 420px phone while the layout
insisted it had 611px. A flex item is as wide as its content unless told
otherwise. */
const fits = await p.evaluate(() => {
let el = document.querySelector("[data-pdf-page]")?.parentElement;
while (el && getComputedStyle(el).overflow !== "auto") el = el.parentElement;
return { scroller: el?.clientWidth ?? 0, window: window.innerWidth };
});
check(
fits.scroller <= fits.window,
"and the document area does not push itself past the window",
`${fits.scroller} vs ${fits.window}`
);
const marker = p.locator("[data-pin]").first();
const markerText = await marker.getAttribute("title");
await marker.scrollIntoViewIfNeeded();
await marker.click();
await p.waitForTimeout(800);
check((await p.locator("[data-pin-sheet]").count()) === 1, "tapping the marker opens a sheet");
check(
(await p.getByLabel("Pinned note").inputValue()) === markerText,
"with the note that was written on the desktop",
markerText
);
const sheet = await p.locator("[data-pin-sheet]").boundingBox();
check(
sheet.y + sheet.height > 700,
"at the bottom, where a thumb reaches",
`top ${Math.round(sheet.y)} of 820`
);
await p.getByRole("button", { name: "Done" }).click();
await p.waitForTimeout(800);
check((await p.locator("[data-pin-sheet]").count()) === 0, "and closes again");
await p.setViewportSize({ width: 1400, height: 950 });
await p.waitForTimeout(2000);
/* ── the notes panel lives in the same toolbar now ── */
check(
(await p.locator("[data-pdf-toggle-notes]").count()) === 1,
"the notes control sits with the document's other controls"
);
const sameBar = await p.evaluate(() => {
const notes = document.querySelector("[data-pdf-toggle-notes]");
const mode = document.querySelector("[data-pdf-mode]");
return notes && mode ? notes.parentElement === mode.parentElement : false;
});
check(sameBar, "in the one toolbar, not in a row of its own above it");
} catch (e) {
check(false, "suite ran to the end", e.message);
} finally {
await session?.cleanup();
const code = report();
await browser.close();
process.exit(code);
}