Files
exercise_builder/app/document.js
T
ryan c18510b3c3 Import the exercise builder
The drag-and-drop workspace for building BI122G exercise sheets, served
by the Workshop at /projects/exercise-builder. Plain HTML, CSS and ES
modules; no build step. It runs entirely in the browser — work in
progress lives in localStorage and finished sheets leave as downloads,
so nothing is written on the server.

This directory is canonical. It began as a deployment target for
deploy_builder.sh syncing from the BI122G course folder, and that
arrangement has ended; see the README.

Includes the sheet's light/dark switch: the stylesheet already carried
both themes and followed the reader's system setting, so this adds the
control that overrides it — a corner toggle built by sheet.js, applied
before first paint by a head script the exporter writes, and hidden in
print. The builder's toolbar previews the same attribute on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ed5kU3HfZq9dfj7EYj5J2v
2026-08-11 19:59:59 +02:00

89 lines
2.9 KiB
JavaScript

/* =====================================================================
Assembling and reading whole sheet files.
The preview iframe and the exported file are built from the same
renderer, so what you arrange on the canvas is what lands on disk.
The only difference is that the preview carries editing chrome and
omits the click-to-copy script, because on the canvas a click selects.
===================================================================== */
import { renderBody } from './render.js';
let cache = null;
export async function assets() {
if (!cache) {
const [css, js, logo] = await Promise.all([
fetch('./sheet.css').then((r) => r.text()),
fetch('./sheet.js').then((r) => r.text()),
fetch('./logo.txt').then((r) => r.text()).catch(() => ''),
]);
cache = { css, js, logo: logo.trim() };
}
return cache;
}
const esc = (s) => String(s).replace(/[&<>]/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
function title(doc) {
const code = (doc.meta.courseCode || '').trim() || 'Exercise';
return `${code} · ${doc.meta.exercise} · ${doc.meta.title}`;
}
/** A complete, self-contained sheet — what Export writes. */
export function wholeFile(doc, { css, js }) {
return [
'<!DOCTYPE html>',
'<html lang="en">',
'<head>',
'<meta charset="utf-8">',
'<meta name="viewport" content="width=device-width, initial-scale=1">',
`<title>${esc(title(doc))}</title>`,
'',
// A reader who overrode their system setting has to have it applied
// before the first paint, or every load of a dark sheet starts with a
// white flash. That rules out sheet.js, which runs at the end of the
// body — so the read happens here and the switch itself is still built
// down there. The key is shared with sheet.js; keep the two in step.
'<script>',
'try{var t=localStorage.getItem("bi122g-sheet-theme");',
'if(t==="dark"||t==="light")document.documentElement.setAttribute("data-theme",t)}catch(e){}',
'<\/script>',
'',
'<style>',
css.trim(),
'</style>',
'</head>',
'<body>',
renderBody(doc),
'',
'<script>',
js.trim(),
'<\/script>',
'</body>',
'</html>',
'',
].join('\n');
}
/** An empty shell for the canvas; the body is filled on every update. */
export function previewShell({ css }, chromeCSS) {
return [
'<!DOCTYPE html>',
'<html lang="en">',
'<head><meta charset="utf-8">',
'<style>', css.trim(), chromeCSS, '</style>',
'</head><body></body></html>',
].join('\n');
}
export function download(name, text, type = 'text/html;charset=utf-8') {
const url = URL.createObjectURL(new Blob([text], { type }));
const a = Object.assign(document.createElement('a'), { href: url, download: name });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}