Files
Steffen Becker 284fdae43e docs: the showcase screenshots are shot at twice the pixel density
openApp() passes page options through, so the showcase run sets
deviceScaleFactor 2 — same CSS viewport and layout as the tests, 2800x2000 px
images that stay sharp where the README scales them down.
2026-09-06 21:19:14 +02:00

116 lines
6.2 KiB
JavaScript

// Builds the showcase documents and their screenshots into docs/showcase/. The document
// modules are TypeScript the browser imports straight from the dev server, and the app's
// own exporters write the .odt/.docx; each is then opened through the file input and shot.
// `node scripts/showcase/run.mjs [regex]` limits the run to matching document names.
import { writeFile, mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { ROOT, openApp, devServer } from '../../tests/browser.mjs';
const PORT = 4187;
const OUT = join(ROOT, 'docs/showcase');
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
const DOCS = ['thesis', 'thesis-review', 'book', 'newsletter'].filter((n) => !only || only.test(n));
await mkdir(OUT, { recursive: true });
const server = await devServer(PORT);
// Same CSS viewport as the tests, shot at 2x so the README images stay sharp when scaled.
const { browser, page, pageErrors } = await openApp(PORT, { deviceScaleFactor: 2 });
const url = `http://localhost:${PORT}/`;
const ready = async () => {
await page.waitForSelector('.tiptap', { timeout: 30_000 });
// The page count settles a while after the last reflow; wait for it to hold still.
let last = '';
for (let i = 0; i < 40; i++) {
await page.waitForTimeout(500);
const now = await page.evaluate(() => document.querySelector('.statusbar')?.textContent ?? '');
if (now === last && now.includes('of')) break;
last = now;
}
};
const reloadWith = async (settings) => {
await page.evaluate((s) => { for (const [k, v] of Object.entries(s)) localStorage.setItem(k, v); }, settings);
await page.reload({ waitUntil: 'load' });
await ready();
};
try {
await page.goto(url, { waitUntil: 'load' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
for (const name of DOCS) {
const built = await page.evaluate(async (name) => {
const [{ buildOdt }, { buildDocx }, { exportArgs }, mod] = await Promise.all([
import('/src/lib/export/odt.ts'), import('/src/lib/export/docx.ts'),
import('/scripts/showcase/lib.ts'), import(`/scripts/showcase/${name}.ts`),
]);
const s = await mod.build();
const b64 = (u8) => {
let out = '';
for (let i = 0; i < u8.length; i += 0x8000) out += String.fromCharCode(...u8.subarray(i, i + 0x8000));
return btoa(out);
};
const args = exportArgs(s);
return { odt: b64(await buildOdt(...args)), docx: b64(await buildDocx(...args)), shots: s.shots };
}, name);
const odt = join(OUT, `${name}.odt`);
await writeFile(odt, Buffer.from(built.odt, 'base64'));
await writeFile(join(OUT, `${name}.docx`), Buffer.from(built.docx, 'base64'));
console.log(`${name}: .odt ${built.odt.length * 3 / 4 | 0} B, .docx ${built.docx.length * 3 / 4 | 0} B`);
// The autosave holds the document across the reloads a theme or zoom change takes;
// its pictures follow into IndexedDB a moment after the JSON.
const before = await page.evaluate(() => localStorage.getItem('edentext-doc'));
await page.setInputFiles('input.file-input', odt);
await ready();
await page.waitForFunction((b) => localStorage.getItem('edentext-doc') !== b, before, { timeout: 15_000 });
await page.waitForTimeout(2000);
for (const shot of built.shots) {
await reloadWith({
'edentext-theme': shot.theme ?? 'light',
'edentext-zoom': String(shot.zoom ?? 100),
'edentext-markup-mode': shot.markup ? 'all' : 'none',
'edentext-page-columns': String(shot.columns ?? 1),
});
// Unknown names would wear squiggles in every shot; the review shot keeps them. The
// floating table toolbar would cover the table the ribbon is already showing.
await page.addStyleTag({ content: `.table-toolbar { display: none !important; }
${shot.spelling ? '' : '.pm-spell-error { text-decoration: none !important; background: none !important; }'}` });
// One .paper holds every page; page n starts n-1 page cycles down it. The caret
// goes to the first paragraph in view, so the ribbon shows the body's formatting.
await page.evaluate(({ page: n, at, caret }) => {
const editor = document.querySelector('.editor');
const paper = document.querySelector('.paper');
const cycle = parseFloat(getComputedStyle(paper).getPropertyValue('--page-height')) + 20;
const zoom = (parseFloat(localStorage.getItem('edentext-zoom')) || 100) / 100;
const top = paper.getBoundingClientRect().top - editor.getBoundingClientRect().top + editor.scrollTop;
if (at) {
const h = [...document.querySelectorAll('.tiptap :is(h1, h2, h3, p)')].find((e) => e.textContent.trim() === at);
n = Math.floor((h.getBoundingClientRect().top - paper.getBoundingClientRect().top) / (cycle * zoom)) + 1;
}
editor.scrollTop = top + (n - 1) * cycle * zoom - 12;
const limit = editor.getBoundingClientRect().top + 40;
const plain = (p) => [...p.querySelectorAll('*')].every((e) => /^(SPAN|STRONG|EM|B|I|U|S|A|SUP|SUB|BR)$/.test(e.tagName));
// A `caret` selector puts it in that element instead — a table cell brings up the
// contextual tabs, so the ribbon shows what the shot is about.
const first = caret
? [...document.querySelectorAll(caret)].find((e) => e.getBoundingClientRect().top > limit)
: [...document.querySelectorAll('.tiptap > p')].find((p) => p.getBoundingClientRect().top > limit && p.textContent.trim() && plain(p));
const view = document.querySelector('.tiptap').editor;
if (first) view.commands.focus(view.view.posAtDOM(first, 0) + 1, { scrollIntoView: false });
}, { page: shot.page ?? 1, at: shot.at, caret: shot.caret });
if (shot.tab) await page.click(`.ribbon-tab[data-tab="${shot.tab}"]`);
await page.waitForTimeout(500);
await page.screenshot({ path: join(OUT, `${shot.file}.png`) });
console.log(` ${shot.file}.png`);
}
}
} finally {
if (pageErrors.length) console.log(`page errors: ${pageErrors.join(' | ')}`);
await browser.close();
if (server) process.kill(-server.pid);
}