The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
633 lines
27 KiB
JavaScript
633 lines
27 KiB
JavaScript
/* 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 {
|
||
captureDownloads,
|
||
check,
|
||
clearDownloads,
|
||
createNotebook,
|
||
downloads,
|
||
lastDownloadName,
|
||
launch,
|
||
report,
|
||
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 p.getByTitle("Import 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");
|
||
check(
|
||
(await p.locator("[data-pdf-position]").innerText()).includes("/ 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 p.getByPlaceholder("Search notes...").fill("Zebrastreifen");
|
||
await p.waitForTimeout(1500);
|
||
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 p.getByPlaceholder("Search notes...").fill("Wortdasnichtvorkommt");
|
||
await p.waitForTimeout(1200);
|
||
check((await p.locator("[data-note-row]").count()) === 0, "and a word that is not in it finds nothing");
|
||
await p.getByPlaceholder("Search notes...").fill("");
|
||
await p.waitForTimeout(1000);
|
||
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("Größer").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("Kleiner").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 p.getByPlaceholder("Search notes...").fill("Randbemerkung");
|
||
await p.waitForTimeout(1500);
|
||
check((await p.locator("[data-note-row]").count()) === 1, "notes written beside a PDF are searchable");
|
||
await p.getByPlaceholder("Search notes...").fill("");
|
||
await p.waitForTimeout(1000);
|
||
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").click();
|
||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||
await p.waitForTimeout(1200);
|
||
};
|
||
|
||
await openExport();
|
||
check(
|
||
(await p.getByText("Was soll heraus?").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: /PDF im Original/ }).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 mit deinen Anmerkungen/ }).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(/mit Notizen/.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 pin");
|
||
const bubble = p.getByLabel("Pinned note");
|
||
check(await bubble.isVisible(), "and opens straight into writing");
|
||
|
||
await bubble.fill("Drehmoment beachten");
|
||
await p.getByRole("button", { name: "Fertig" }).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("Größer").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("Kleiner").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 p.getByPlaceholder("Search notes...").fill("Drehmoment");
|
||
await p.waitForTimeout(1500);
|
||
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 p.getByPlaceholder("Search notes...").fill("");
|
||
await p.waitForTimeout(1000);
|
||
|
||
/* 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");
|
||
|
||
await p.locator("[data-pin]").click();
|
||
await p.waitForTimeout(600);
|
||
check(
|
||
(await p.getByLabel("Pinned note").inputValue()) === "Drehmoment beachten",
|
||
"with the text that was written into it"
|
||
);
|
||
|
||
/* 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("Seite leeren").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: "Fertig" }).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").click();
|
||
await p.waitForSelector("[data-export-dialog]", { timeout: 10000 });
|
||
await p.waitForTimeout(800);
|
||
await p.getByRole("button", { name: /PDF mit deinen Anmerkungen/ }).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);
|
||
|
||
|
||
|
||
/* ── 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);
|
||
}
|