Files
exercise_builder/app/app.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

1351 lines
45 KiB
JavaScript
Raw 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.
/* =====================================================================
Exercise builder — workspace
Three panes over one document: palette, canvas, inspector. The canvas
is a same-origin iframe rendering the real sheet, so the preview is
literally the output rather than an approximation of it.
===================================================================== */
import * as M from './model.js';
import { renderBody } from './render.js';
import { importSheet } from './import.js';
import { assets, wholeFile, previewShell, download } from './document.js';
const $ = (id) => document.getElementById(id);
const STORE = 'bi122g-builder-doc';
const CLIP = 'bi122g-builder-clip';
const PREVIEW = 'bi122g-builder-preview';
let doc = M.emptyDoc();
let selected = null;
let history = [];
let A = null; // css / js / logo
let frameDoc = null;
let previewTheme = localStorage.getItem(PREVIEW) === 'dark' ? 'dark' : 'light';
/* ---------- editing chrome injected into the canvas ---------- */
const CHROME = `
[data-node]{ position: relative; }
[data-node]:hover{ outline: 1px dashed #6FBCE8; outline-offset: 3px; }
[data-node].sel{ outline: 2px solid #5DBE84 !important; outline-offset: 3px; }
section.part[data-node].sel{ outline-offset: 6px; }
body{ padding-bottom: 40vh; }
/* the grip is the only thing that starts a drag, so pressing on a block
never turns into a text selection */
#__grip{
position: absolute; z-index: 9999; display: none;
width: 24px; height: 26px; border-radius: 4px 0 0 4px;
background: #6FBCE8; color: #0B1116;
font: 700 13px/26px "Segoe UI", system-ui, sans-serif;
text-align: center; cursor: grab;
user-select: none; -webkit-user-select: none;
box-shadow: 0 1px 4px rgba(0,0,0,.45);
}
#__grip:hover{ background: #5DBE84; }
#__grip:active{ cursor: grabbing; }
body.__dragging, body.__dragging *{
user-select: none !important; -webkit-user-select: none !important;
}
`;
/* ---------- boot ---------- */
(async function boot() {
A = await assets();
const saved = localStorage.getItem(STORE);
if (saved) {
try { doc = M.migrate(JSON.parse(saved)); } catch { doc = M.emptyDoc(); }
} else {
doc = starter();
}
buildPalette();
await mountCanvas();
update();
wireToolbar();
wireKeys();
// Served from the Workshop, the builder fills the window and the site nav is
// gone — so offer a way back. Opened locally, that path does not exist.
if (location.pathname.startsWith('/projects/exercise-builder')) $('bHome').hidden = false;
})();
function starter() {
const d = M.emptyDoc();
d.meta.logo = A.logo;
d.meta.exercise = 'Exercise 1';
d.meta.title = 'Untitled sheet';
d.intro.push({ id: M.uid('l'), kind: 'legend' });
const p = M.blank('part');
p.id = 'first-part';
p.title = 'First part';
M.insert(p.children, M.blank('prose'));
M.insert(d.parts, p);
return d;
}
/* ---------- canvas ---------- */
async function mountCanvas() {
const frame = $('canvas');
await new Promise((res) => {
frame.addEventListener('load', res, { once: true });
frame.srcdoc = previewShell(A, CHROME);
});
frameDoc = frame.contentDocument;
applyPreviewTheme();
frameDoc.addEventListener('click', (e) => {
const el = e.target.closest('[data-node]');
if (!el) { select(null); return; }
e.preventDefault();
select(el.dataset.node);
});
grip = frameDoc.createElement('div');
grip.id = '__grip';
grip.textContent = '⠿';
grip.title = 'Drag to move';
frameDoc.body.appendChild(grip);
grip.addEventListener('pointerdown', (e) => {
if (e.button !== 0 || !gripFor) return;
e.preventDefault(); // no text selection, no caret
startDrag(e, { move: gripFor }, frame);
});
grip.addEventListener('mouseenter', () => clearTimeout(hideTimer));
frameDoc.addEventListener('mousemove', (e) => {
if (drag) return;
if (e.target.id === '__grip') { clearTimeout(hideTimer); return; }
const el = e.target.closest('[data-node]');
if (el) placeGrip(el); else scheduleHide();
});
frameDoc.addEventListener('mouseleave', scheduleHide);
}
const GRIP_W = 24;
let grip = null;
let gripFor = null;
let hideTimer = null;
function placeGrip(el) {
if (!grip) return;
clearTimeout(hideTimer);
const r = el.getBoundingClientRect();
const d = frameDoc.documentElement;
gripFor = el.dataset.node;
grip.style.display = 'block';
grip.style.top = `${r.top + d.scrollTop}px`;
// flush against the block's left edge, so travelling to it never crosses
// a dead zone; if there is no room outside, sit just inside instead
const outside = r.left + d.scrollLeft - GRIP_W;
grip.style.left = `${outside < 2 ? r.left + d.scrollLeft + 2 : outside}px`;
}
/**
* Hiding is deferred, so a diagonal path from block to grip — or a moment
* spent over the gap at the page edge — does not snatch it away.
*/
function scheduleHide() {
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (selected) {
const el = frameDoc.querySelector(`[data-node="${CSS.escape(selected)}"]`);
if (el) { placeGrip(el); return; } // the selected block keeps its grip
}
if (grip) { grip.style.display = 'none'; gripFor = null; }
}, 260);
}
function hideGrip() {
clearTimeout(hideTimer);
if (!grip) return;
grip.style.display = 'none';
gripFor = null;
}
/* The sheet's own light/dark switch, previewed.
This sets exactly the attribute the exported sheet's toggle sets, on
exactly the same stylesheet — so the canvas shows what a reader who
flipped that switch will see, rather than an impression of it.
It is a view setting and not part of the document: nothing here
touches `doc`, and the export never writes a data-theme, so a sheet
always opens on its reader's own preference no matter which way the
canvas happened to be left. Survives a paint (only the body is
replaced) but not a remount, hence the call in mountCanvas. */
function applyPreviewTheme() {
if (frameDoc) frameDoc.documentElement.dataset.theme = previewTheme;
const b = $('bTheme');
const other = previewTheme === 'dark' ? 'light' : 'dark';
b.textContent = previewTheme === 'dark' ? 'Sheet: ☾ dark' : 'Sheet: ☀ light';
b.title = `Preview the sheet in ${other} mode`;
}
function paint() {
if (!frameDoc) return;
const scroll = frameDoc.documentElement.scrollTop;
frameDoc.body.innerHTML = renderBody(doc, { marks: true });
if (grip) { frameDoc.body.appendChild(grip); hideGrip(); }
if (selected) {
const el = frameDoc.querySelector(`[data-node="${CSS.escape(selected)}"]`);
if (el) { el.classList.add('sel'); placeGrip(el); }
}
frameDoc.documentElement.scrollTop = scroll;
}
/* ---------- state ---------- */
function snapshot() {
history.push(JSON.stringify(doc));
if (history.length > 60) history.shift();
}
function update(msg = 'unsaved', cls = 'dirty') {
paint();
drawInspector();
localStorage.setItem(STORE, JSON.stringify(doc));
const s = $('status');
s.textContent = msg;
s.className = 'status ' + cls;
$('bUndo').disabled = history.length === 0;
}
function select(id) {
applyEdits();
selected = id;
paint();
drawInspector();
}
/* ---------- palette ---------- */
function buildPalette() {
const box = $('palette');
box.innerHTML = '';
let group = null;
M.PALETTE.forEach((item) => {
if (item.group !== group) {
group = item.group;
const h = document.createElement('h3');
h.className = 'group';
h.textContent = group;
box.appendChild(h);
}
const b = document.createElement('button');
b.className = 'chip';
b.innerHTML = `<b>${item.label}</b><span>${item.hint}</span>`;
b.addEventListener('click', () => addNode(item.kind, item.preset));
b.addEventListener('pointerdown', (e) => {
e.preventDefault();
startDrag(e, { add: item.kind, preset: item.preset }, b);
});
box.appendChild(b);
});
}
/** Where a new node of this kind should land, given what is selected. */
function targetFor(kind) {
if (kind === 'part') return { list: doc.parts, index: insertionIndexFor(doc.parts) };
const sel = selected && M.find(doc, selected);
if (!sel) {
const last = doc.parts[doc.parts.length - 1];
return last ? { list: last.children, index: null } : null;
}
// into the selection if it can hold this, else alongside it
if (Array.isArray(sel.node.children) && M.accepts(sel.node.kind, kind)) {
return { list: sel.node.children, index: null };
}
const ownerKind = sel.owner ? sel.owner.kind : 'root';
if (M.accepts(ownerKind, kind)) return { list: sel.list, index: sel.index + 1 };
return null;
}
function insertionIndexFor(list) {
const sel = selected && M.find(doc, selected);
if (sel && sel.list === list) return sel.index + 1;
return list.length;
}
function addNode(kind, preset) {
applyEdits();
const where = targetFor(kind);
if (!where) { flash('nowhere to put a ' + kind); return; }
snapshot();
const node = spawn(kind, preset);
M.insert(where.list, node, where.index);
selected = node.id;
update();
}
/** A fresh node with its palette preset applied. */
function spawn(kind, preset) {
const node = Object.assign(M.blank(kind), preset || {});
if (kind === 'part') {
node.id = uniquePartId(node.track === 'side' ? 'side-quest' : 'new-part');
node.title = node.track === 'side' ? 'New side quest' : 'New part';
}
return node;
}
function uniquePartId(base) {
const taken = new Set(doc.parts.map((p) => p.id));
if (!taken.has(base)) return base;
let n = 2;
while (taken.has(`${base}-${n}`)) n += 1;
return `${base}-${n}`;
}
/* ---------- drag and drop ---------- */
let drag = null;
function startDrag(e, what, originEl) {
if (e.button !== 0) return;
drag = { ...what, from: { x: e.clientX, y: e.clientY }, live: false, target: null };
const frameRect = $('canvas').getBoundingClientRect();
const inFrame = originEl === $('canvas');
const pageX = (x) => (inFrame ? x + frameRect.left : x);
const pageY = (y) => (inFrame ? y + frameRect.top : y);
const onMove = (ev) => {
const cx = pageX(ev.clientX), cy = pageY(ev.clientY);
if (!drag.live) {
if (Math.hypot(cx - pageX(drag.from.x), cy - pageY(drag.from.y)) < 5) return;
drag.live = true;
frameDoc.body.classList.add('__dragging');
$('ghost').style.display = 'block';
$('ghost').textContent = drag.add ? `add ${drag.add}` : 'move';
}
Object.assign($('ghost').style, { left: cx + 12 + 'px', top: cy + 12 + 'px' });
drag.target = findDropTarget(cx, cy, frameRect);
showCaret(drag.target, frameRect);
};
const onUp = () => {
document.removeEventListener('pointermove', onMove, true);
document.removeEventListener('pointerup', onUp, true);
$('canvas').contentDocument.removeEventListener('pointermove', onMove, true);
$('canvas').contentDocument.removeEventListener('pointerup', onUp, true);
frameDoc.body.classList.remove('__dragging');
$('ghost').style.display = 'none';
$('drop').style.display = 'none';
if (drag && drag.live && drag.target) commitDrop(drag);
drag = null;
};
document.addEventListener('pointermove', onMove, true);
document.addEventListener('pointerup', onUp, true);
$('canvas').contentDocument.addEventListener('pointermove', onMove, true);
$('canvas').contentDocument.addEventListener('pointerup', onUp, true);
}
/** Nearest legal insertion point to a viewport coordinate. */
function findDropTarget(cx, cy, frameRect) {
const kind = drag.add || (M.find(doc, drag.move) || {}).node?.kind;
if (!kind) return null;
const fx = cx - frameRect.left, fy = cy - frameRect.top;
if (fx < 0 || fy < 0 || fx > frameRect.width || fy > frameRect.height) return null;
const el = frameDoc.elementFromPoint(fx, fy);
if (!el) return null;
let node = el.closest('[data-node]');
while (node) {
const found = M.find(doc, node.dataset.node);
if (found) {
const ownerKind = found.owner ? found.owner.kind : 'root';
if (M.accepts(ownerKind, kind)) {
const box = node.getBoundingClientRect();
const after = (fy - box.top) > box.height / 2;
return { list: found.list, index: found.index + (after ? 1 : 0), el: node, after };
}
// cannot sit beside it — try inside it
if (Array.isArray(found.node.children) && M.accepts(found.node.kind, kind)) {
return { list: found.node.children, index: found.node.children.length, el: node, after: true };
}
}
node = node.parentElement && node.parentElement.closest('[data-node]');
}
if (kind === 'part') return { list: doc.parts, index: doc.parts.length, el: null, after: true };
return null;
}
function showCaret(target, frameRect) {
const bar = $('drop');
if (!target || !target.el) { bar.style.display = 'none'; return; }
const b = target.el.getBoundingClientRect();
Object.assign(bar.style, {
display: 'block',
left: frameRect.left + b.left + 'px',
width: b.width + 'px',
top: frameRect.top + (target.after ? b.bottom : b.top) - 1 + 'px',
});
}
function commitDrop(d) {
applyEdits();
snapshot();
if (d.add) {
const node = spawn(d.add, d.preset);
M.insert(d.target.list, node, d.target.index);
selected = node.id;
} else {
if (!M.move(doc, d.move, d.target.list, d.target.index)) {
history.pop();
flash('cannot drop that there');
return;
}
selected = d.move;
}
update();
}
/* ---------- clipboard ---------- */
/** Held in localStorage so it survives a reload and crosses builder tabs. */
function readClip() {
try { return JSON.parse(localStorage.getItem(CLIP) || 'null'); } catch { return null; }
}
function copySelection(alsoCut = false) {
const sel = selected && M.find(doc, selected);
if (!sel) return false;
localStorage.setItem(CLIP, JSON.stringify(sel.node));
if (alsoCut) {
snapshot();
M.remove(doc, sel.node.id);
selected = null;
update(`cut ${labelFor(sel.node.kind, sel.node).toLowerCase()}`, 'good');
} else {
drawInspector();
flashGood(`copied ${labelFor(sel.node.kind, sel.node).toLowerCase()}`);
}
return true;
}
function pasteClip() {
applyEdits();
const clip = readClip();
if (!clip) { flash('nothing copied yet'); return false; }
const where = targetFor(clip.kind);
if (!where) { flash(`a ${clip.kind} cannot go there`); return false; }
snapshot();
const node = M.cloneFresh(clip); // fresh ids all the way down
if (node.kind === 'part') {
node.id = uniquePartId(node.id || 'new-part');
}
M.insert(where.list, node, where.index);
selected = node.id;
const n = countNodes(node) - 1;
update(n ? `pasted with ${n} nested block${n === 1 ? '' : 's'}` : 'pasted', 'good');
return true;
}
function countNodes(node) {
return 1 + (node.children || []).reduce((a, c) => a + countNodes(c), 0);
}
function flashGood(msg) {
const s = $('status');
s.textContent = msg;
s.className = 'status good';
}
/* ---------- inspector ---------- */
/*
* Text fields do not write straight through. They collect into `pending`
* and land on Apply, on blur, or on Ctrl+S — so the canvas can update
* without the field losing focus, which redrawing the panel would cause.
*/
let pending = [];
let inspectorDirty = false;
function persist() { localStorage.setItem(STORE, JSON.stringify(doc)); }
/** Register a control whose value is applied later. */
function defer(control, apply) {
pending.push(apply);
control.addEventListener('input', () => markDirty(true));
control.addEventListener('change', () => applyEdits());
control.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'Enter')) {
e.preventDefault();
applyEdits();
}
});
}
function markDirty(on) {
inspectorDirty = on;
const b = $('applyBtn');
if (!b) return;
b.disabled = !on;
b.textContent = on ? 'Apply' : 'Applied';
b.className = on ? 'primary' : '';
}
/** Commit every pending field at once, keeping focus where it is. */
function applyEdits() {
if (!inspectorDirty) return;
snapshot();
pending.forEach((fn) => { try { fn(); } catch (err) { console.error(err); } });
markDirty(false);
paint();
persist();
flashGood('applied');
}
/** For controls with no typing state — a checkbox, a dropdown. */
function applyNow(mutate) {
snapshot();
mutate();
paint();
persist();
drawInspector();
flashGood('applied');
}
function drawInspector() {
const box = $('inspector');
box.innerHTML = '';
pending = [];
inspectorDirty = false;
const sel = selected && M.find(doc, selected);
if (!sel) {
box.innerHTML = `<p class="empty">Nothing selected.<br><br>
Click a block on the canvas to edit it, or drag a component in from the left.
<br><br>New blocks land inside whatever is selected.</p>`;
// (fields elsewhere apply with the Apply button, Ctrl+S, or on leaving them)
const clip = readClip();
if (clip) {
const row = el('div', 'rowbtns');
row.appendChild(mkBtn(`Paste ${labelFor(clip.kind, clip).toLowerCase()}`, pasteClip, 'primary'));
box.appendChild(row);
}
return;
}
const node = sel.node;
const head = el('div', 'kindline');
head.innerHTML = `<b>${labelFor(node.kind, node)}</b><span class="pill">${node.kind}</span>`;
const spacer = el('span'); spacer.style.flex = '1';
const apply = el('button'); apply.id = 'applyBtn';
apply.textContent = 'Applied';
apply.disabled = true;
apply.title = 'Apply changes (Ctrl+S)';
apply.addEventListener('click', applyEdits);
head.append(spacer, apply);
box.appendChild(head);
fields(node).forEach((f) => box.appendChild(f));
const clip = readClip();
const btns = el('div', 'rowbtns');
btns.append(
mkBtn('Move up', () => { snapshot(); M.nudge(doc, node.id, -1); update(); }),
mkBtn('Move down', () => { snapshot(); M.nudge(doc, node.id, 1); update(); }),
mkBtn('Copy', () => copySelection(), ''),
mkBtn('Cut', () => copySelection(true), ''),
mkBtn(clip ? `Paste ${labelFor(clip.kind, clip).toLowerCase()}` : 'Paste', pasteClip),
mkBtn('Duplicate', () => {
snapshot();
const c = M.duplicate(doc, node.id);
if (c) selected = c.id;
update();
}),
mkBtn('Delete', () => {
snapshot(); M.remove(doc, node.id); selected = null; update();
}, 'danger'),
);
box.appendChild(btns);
}
function labelFor(kind, node) {
if (kind === 'part') return node && node.track === 'side' ? 'Side quest' : 'Core';
const p = M.PALETTE.find((x) => x.kind === kind);
if (p) return p.label;
return kind === 'sidequest' ? 'Side quest (legacy)'
: kind === 'legend' ? 'Legend'
: kind === 'heading' ? 'Closing heading' : kind;
}
function fields(node) {
const set = (k, v) => { node[k] = v; };
const out = [];
switch (node.kind) {
case 'part':
out.push(
text('Title', node.title, (v) => set('title', v)),
choose('Track', node.track, [['core', 'Core'], ['side', 'Side quest']], (v) => set('track', v)),
text('Reference id', node.id, (v) => {
const old = node.id;
node.id = v.trim() || old;
retarget(old, node.id);
selected = node.id;
}),
hint(`Cross-reference this part from any prose with <code>{{part:${node.id}}}</code> — it renders the current number and never goes stale.`),
);
break;
case 'prose':
out.push(
area('Text', node.text, 6, (v) => set('text', v)),
choose('Style', node.variant || '', [['', 'Normal'], ['note', 'Quiet note']], (v) => set('variant', v || null)),
markupHint(),
);
break;
case 'cmd':
out.push(text('Command', node.command, (v) => set('command', v)));
break;
case 'terminal':
out.push(text('Caption', node.caption, (v) => set('caption', v)));
if (simpleTerminal(node)) {
const prompt = promptOf(node);
out.push(
text('Prompt', prompt, (v) => {
node.lines = textToTerm(termToText(node), v || '$');
}),
area('Lines', termToText(node), 9, (v) => set('lines', textToTerm(v, prompt))),
hint('One line each. <code>$ </code> marks a typed command, <code>! </code> an error, anything else is output.'),
mkBtn('Capture a real session…', () => openCapture(node)),
);
} else {
out.push(hint('This transcript mixes several roles on one line, which the line editor cannot represent without flattening it. Capture over it, or delete and rebuild it.', true),
mkBtn('Capture a real session…', () => openCapture(node)));
}
break;
case 'protip':
out.push(
text('Keys', (node.keys || []).join(' + '), (v) =>
set('keys', v.split('+').map((s) => s.trim()).filter(Boolean))),
text('Label', node.label, (v) => set('label', v)),
area('Text', node.text, 6, (v) => set('text', v)),
check('Emphasised', node.emphasis, (v) => set('emphasis', v)),
markupHint(),
);
break;
case 'callout':
out.push(text('Heading', node.heading, (v) => set('heading', v)));
break;
case 'sidequest':
out.push(
text('Summary', node.summary, (v) => set('summary', v)),
hint('A leftover from before side quests became whole parts. It still renders, but the palette will not make new ones.', true),
);
break;
case 'list':
out.push(
area('Items', node.items.join('\n'), 6, (v) =>
set('items', v.split('\n').map((s) => s.trim()).filter(Boolean))),
hint('One bullet per line.'),
);
break;
case 'table': {
const dims = document.createElement('div');
dims.className = 'hintbox';
dims.textContent = `${node.rows.length} rows × ${node.columns.length} columns`;
const open = mkBtn('Edit table…', () => openTableEditor(node), 'primary');
out.push(dims, open);
break;
}
case 'heading':
out.push(text('Text', node.text, (v) => set('text', v)));
break;
case 'task':
out.push(hint('A numbered step. Drag prose, commands or terminals into it. Consecutive steps in a part share one numbered list automatically.'));
break;
default:
out.push(hint('No settings for this block.'));
}
return out;
}
function simpleTerminal(node) {
return (node.lines || []).every((segs) =>
segs.length === 1
? ['o', 'e'].includes(segs[0].role)
: segs.length === 3 && segs[0].role === 'p' && segs[1].role === null && segs[2].role === 'c');
}
function promptOf(node) {
for (const segs of node.lines || []) {
const p = segs.find((x) => x.role === 'p');
if (p) return p.text;
}
return '$';
}
function termToText(node) {
return (node.lines || []).map((segs) => {
if (segs.length === 3) return '$ ' + segs[2].text;
if (segs[0].role === 'e') return '! ' + segs[0].text;
return segs[0].text;
}).join('\n');
}
function textToTerm(v, prompt = '$') {
return v.split('\n').map((line) => {
if (line.startsWith('$ ')) {
return [{ role: 'p', text: prompt }, { role: null, text: ' ' }, { role: 'c', text: line.slice(2) }];
}
if (line.startsWith('! ')) return [{ role: 'e', text: line.slice(2) }];
return [{ role: 'o', text: line }];
});
}
/* ---------- capturing a real session ---------- */
/*
* A shell prompt. Built from four optional pieces so the common shapes all
* match, including an activated environment, which prefixes the prompt and
* otherwise makes the whole line look like output:
*
* (base) rpotter@Hades:/path$ cmd conda / venv
* [rpotter@Hades ~]$ cmd bracketed PS1
* a26benul@hs.local@ibilinux1:~$ cmd ibilinux1
* PS C:\Users\x> cmd PowerShell
* $ cmd bare
*/
const ENV = '(?:\\([^)]*\\)\\s*)*'; // (base) (.venv) …
const PROMPT_RE = new RegExp(
'^\\s*(' // capture the whole prompt
// $ # % > must sit flush against the body, or output such as
// "Note: > redirects output" would read as a typed command
+ ENV
+ '(?:PS\\s+[^>]*' // PowerShell
+ '|\\[[^\\]]*\\]' // [user@host dir]
+ '|[^\\s]*[@:][^\\s]*' // user@host:path
+ ')?[$#%>]'
+ '|'
// and ➜ never appear in command output, so a space before them is safe
+ ENV + '[^\\s]*\\s*[\u276F\u279C]'
+ ')[ \\t]+(.*)$');
const ERROR_RE = new RegExp([
'command not found', 'No such file or directory', 'Permission denied',
'cannot access', 'cannot remove', 'invalid option', 'unrecognized option',
'Operation not permitted', 'not a directory', 'Is a directory',
'^-?[a-z]*sh: ', '^[a-z0-9_.-]+: .*: ', '\\berror\\b', '\\bfailed\\b',
].join('|'), 'i');
/** A pasted transcript → the line format the editor uses, plus the prompt. */
export function parseSession(raw) {
const prompts = [];
const lines = raw.replace(/\r/g, '').split('\n');
// trailing empty prompt lines are the shell waiting, not content
while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
const last = lines[lines.length - 1];
if (last && PROMPT_RE.test(last) && !PROMPT_RE.exec(last)[2].trim()) lines.pop();
const out = lines.map((line) => {
const m = PROMPT_RE.exec(line);
if (m && m[2].trim()) { prompts.push(m[1]); return '$ ' + m[2].trim(); }
if (ERROR_RE.test(line)) return '! ' + line.trim();
return line.replace(/\s+$/, '');
});
// one prompt throughout means it is worth keeping; if it moved with the
// working directory there is no single right answer, so fall back to $
const distinct = [...new Set(prompts)];
const prompt = distinct.length === 1 ? distinct[0] : '$';
return { text: out.join('\n'), prompt, commands: prompts.length };
}
/** Keep {{part:old}} references working when a part id is renamed. */
function retarget(oldId, newId) {
if (oldId === newId) return;
const swap = (s) => String(s).split(`{{part:${oldId}}}`).join(`{{part:${newId}}}`);
M.walk(doc, (n) => {
['text', 'title', 'heading', 'summary'].forEach((k) => {
if (typeof n[k] === 'string') n[k] = swap(n[k]);
});
if (Array.isArray(n.items)) n.items = n.items.map(swap);
if (Array.isArray(n.rows)) n.rows = n.rows.map((r) => r.map(swap));
});
}
/* ---------- little DOM helpers ---------- */
function el(tag, cls) { const n = document.createElement(tag); if (cls) n.className = cls; return n; }
function wrap(labelText, control) {
const l = el('label', 'f');
const i = el('i'); i.textContent = labelText;
l.append(i, control);
return l;
}
function text(labelText, value, onChange) {
const i = el('input'); i.type = 'text'; i.value = value ?? '';
defer(i, () => onChange(i.value));
return wrap(labelText, i);
}
function area(labelText, value, rows, onChange) {
const t = el('textarea'); t.rows = rows; t.value = value ?? '';
defer(t, () => onChange(t.value));
return wrap(labelText, t);
}
// dropdowns and checkboxes have no half-typed state, so they land at once
function choose(labelText, value, options, onChange) {
const s = el('select');
options.forEach(([v, l]) => {
const o = el('option'); o.value = v; o.textContent = l;
if (v === value) o.selected = true;
s.appendChild(o);
});
s.addEventListener('change', () => applyNow(() => onChange(s.value)));
return wrap(labelText, s);
}
function check(labelText, value, onChange) {
const l = el('label', 'cb');
const c = el('input'); c.type = 'checkbox'; c.checked = !!value;
c.addEventListener('change', () => applyNow(() => onChange(c.checked)));
l.append(c, document.createTextNode(labelText));
return l;
}
function hint(html, warn = false) {
const d = el('div', 'hintbox' + (warn ? ' warn' : ''));
d.innerHTML = html;
return d;
}
const markupHint = () => hint(
'Inline: <code>`code`</code>, <code>[[Tab]]</code> for keys, <code>**bold**</code>, ' +
'<code>*italic*</code>, <code>[text](url)</code>, <code>{{part:id}}</code>. Blank line for a break.');
function mkBtn(label, fn, cls) {
const b = el('button', cls); b.textContent = label;
b.addEventListener('click', fn);
return b;
}
function flash(msg) {
const s = $('status');
s.textContent = msg;
s.className = 'status dirty';
}
/* ---------- toolbar ---------- */
function wireToolbar() {
$('bNew').addEventListener('click', () => {
if (!confirm('Discard the current document and start a new one?')) return;
snapshot(); doc = starter(); selected = null; update('new document', 'good');
});
$('bImport').addEventListener('click', () => pick('.html', async (file) => {
const html = await file.text();
const parsed = new DOMParser().parseFromString(html, 'text/html');
try {
snapshot();
doc = M.migrate(importSheet(parsed));
selected = null;
const legacy = M.legacySideQuests(doc).length;
update(legacy ? `imported · ${legacy} legacy side quests` : 'imported', 'good');
} catch (err) {
flash('could not read that file');
console.error(err);
}
}));
$('bOpen').addEventListener('click', () => pick('.json', async (file) => {
try {
snapshot();
doc = M.migrate(JSON.parse(await file.text()));
selected = null;
update('project opened', 'good');
} catch { flash('not a builder project'); }
}));
$('bProject').addEventListener('click', () => {
applyEdits();
download(fileStem() + '.json', JSON.stringify(doc, null, 2), 'application/json');
update('project saved', 'good');
});
$('bExport').addEventListener('click', () => {
applyEdits();
download(fileStem() + '.html', wholeFile(doc, A));
update('sheet exported', 'good');
});
$('bTheme').addEventListener('click', () => {
previewTheme = previewTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem(PREVIEW, previewTheme);
applyPreviewTheme();
});
$('bUndo').addEventListener('click', undo);
$('bDoc').addEventListener('click', documentSettings);
}
function fileStem() {
return (doc.meta.exercise || 'exercise').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
}
function pick(accept, fn) {
const inp = $('filePicker');
inp.accept = accept;
inp.onchange = () => { if (inp.files[0]) fn(inp.files[0]); inp.value = ''; };
inp.click();
}
function undo() {
const prev = history.pop();
if (!prev) return;
doc = JSON.parse(prev);
selected = null;
paint(); drawInspector();
localStorage.setItem(STORE, JSON.stringify(doc));
$('bUndo').disabled = history.length === 0;
flash('undone');
}
function documentSettings() {
selected = null;
paint();
const box = $('inspector');
box.innerHTML = '';
pending = [];
inspectorDirty = false;
const head = el('div', 'kindline');
head.innerHTML = '<b>Document</b><span class="pill">meta</span>';
const spacer = el('span'); spacer.style.flex = '1';
const apply = el('button'); apply.id = 'applyBtn';
apply.textContent = 'Applied';
apply.disabled = true;
apply.title = 'Apply changes (Ctrl+S)';
apply.addEventListener('click', applyEdits);
head.append(spacer, apply);
box.appendChild(head);
const m = doc.meta;
const set = (k, v) => { m[k] = v; };
box.append(
text('Course code', m.courseCode, (v) => set('courseCode', v)),
text('Course name', m.courseName, (v) => set('courseName', v)),
text('Term', m.term, (v) => set('term', v)),
hint('These three run together across the top, separated by <code>·</code>.'),
text('Exercise', m.exercise, (v) => set('exercise', v)),
text('Title', m.title, (v) => set('title', v)),
area('Standfirst', m.standfirst, 4, (v) => set('standfirst', v)),
hint('Legend wording'),
area('Core', (m.legendText || {}).core || '', 3, (v) => {
m.legendText = { ...m.legendText, core: v };
}),
area('Side quest', (m.legendText || {}).side || '', 3, (v) => {
m.legendText = { ...m.legendText, side: v };
}),
area('Protip', (m.legendText || {}).protip || '', 3, (v) => {
m.legendText = { ...m.legendText, protip: v };
}),
check('Show the university logo', !!m.logo, (v) => set('logo', v ? A.logo : '')),
);
}
/* ---------- keyboard ---------- */
function wireKeys() {
// the canvas is an iframe, so its keystrokes never reach the parent
// document — the same handler has to be bound on both
document.addEventListener('keydown', onKey);
if (frameDoc) frameDoc.addEventListener('keydown', onKey);
}
function onKey(e) {
// an open overlay owns the keyboard entirely: its own Escape handler
// closes it, and nothing here may reach the document behind it
if (!$('overlay').hidden) return;
// activeElement belongs to whichever document the key came from
const d = (e.target && e.target.ownerDocument) || document;
const active = d.activeElement;
const typing = !!active && (/^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName) || active.isContentEditable);
// every shortcut, undo included, defers to a field being typed in — the
// browser's own undo belongs to the text, not to the document.
// (Ctrl+S inside a field is caught by defer(), which applies the edit.)
if (typing) return;
const mod = e.ctrlKey || e.metaKey;
if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); applyEdits(); return; }
if (mod && e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); return; }
if (mod && e.key.toLowerCase() === 'v') { e.preventDefault(); pasteClip(); return; }
// leave a genuine text selection to the browser's own copy
const picked = frameDoc && frameDoc.getSelection && !frameDoc.getSelection().isCollapsed;
if (mod && e.key.toLowerCase() === 'c' && selected && !picked) {
e.preventDefault(); copySelection(); return;
}
if (mod && e.key.toLowerCase() === 'x' && selected && !picked) {
e.preventDefault(); copySelection(true); return;
}
if (mod && e.key.toLowerCase() === 'd' && selected) {
e.preventDefault();
snapshot();
const c = M.duplicate(doc, selected);
if (c) selected = c.id;
update();
return;
}
if (!selected) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); snapshot(); M.remove(doc, selected); selected = null; update();
}
if (e.key === 'ArrowUp' && e.altKey) { e.preventDefault(); snapshot(); M.nudge(doc, selected, -1); update(); }
if (e.key === 'ArrowDown' && e.altKey) { e.preventDefault(); snapshot(); M.nudge(doc, selected, 1); update(); }
if (e.key === 'Escape') select(null);
}
/* =====================================================================
Overlay editors — things too big for a 20rem sidebar
===================================================================== */
function openOverlay(titleText, buildBody, onDone, footNodes = []) {
const ov = $('overlay');
$('ovTitle').textContent = titleText;
const body = $('ovBody');
body.innerHTML = '';
const foot = $('ovFoot');
const redraw = () => {
body.innerHTML = '';
foot.innerHTML = '';
buildBody(body, redraw, foot);
if (!foot.querySelector('.grow')) foot.appendChild(el('span', 'grow'));
};
redraw();
ov.hidden = false;
const close = () => {
ov.hidden = true;
$('ovDone').onclick = null;
$('ovCancel').onclick = null;
document.removeEventListener('keydown', onKey, true);
};
const onKey = (e) => {
if (e.key === 'Escape') { e.stopPropagation(); close(); }
};
document.addEventListener('keydown', onKey, true);
$('ovDone').onclick = () => { onDone(); close(); update(); };
$('ovCancel').onclick = close;
}
/* ---------- table ---------- */
function openTableEditor(node) {
// work on a copy so Cancel really cancels
let cols = node.columns.slice();
let rows = node.rows.map((r) => r.slice());
let hasHeader = cols.some((c) => c && c.trim());
let mode = 'grid';
let csv = { raw: '', delim: ',', header: true, name: '' };
const width = () => Math.max(cols.length, ...rows.map((r) => r.length), 1);
const normalise = () => {
const w = width();
while (cols.length < w) cols.push('');
rows.forEach((r) => { while (r.length < w) r.push(''); r.length = w; });
cols.length = w;
};
const cell = (value, onInput) => {
const t = el('textarea');
t.rows = 2;
t.value = value || '';
t.addEventListener('input', () => onInput(t.value));
return t;
};
/* ---------- the grid ---------- */
const buildGrid = (body, redraw, foot) => {
normalise();
const w = width();
const table = el('table', 'grid');
const top = el('tr');
top.appendChild(el('th'));
for (let c = 0; c < w; c += 1) {
const th = el('th', 'axis');
th.append(
mkBtn('+', () => { cols.splice(c, 0, ''); rows.forEach((r) => r.splice(c, 0, '')); redraw(); }, 'colbtn'),
mkBtn('✕', () => {
if (w <= 1) return;
cols.splice(c, 1); rows.forEach((r) => r.splice(c, 1)); redraw();
}, 'colbtn danger'),
);
top.appendChild(th);
}
const thEnd = el('th', 'axis');
thEnd.appendChild(mkBtn('+ column', () => { cols.push(''); rows.forEach((r) => r.push('')); redraw(); }, 'colbtn'));
top.appendChild(thEnd);
table.appendChild(top);
if (hasHeader) {
const hr = el('tr', 'hdr');
hr.appendChild(el('td'));
cols.forEach((c, i) => {
const td = el('td');
td.appendChild(cell(c, (v) => { cols[i] = v; }));
hr.appendChild(td);
});
table.appendChild(hr);
}
rows.forEach((row, r) => {
const tr = el('tr');
const axis = el('td', 'axis');
axis.append(
mkBtn('↑', () => { if (r > 0) { rows.splice(r - 1, 0, rows.splice(r, 1)[0]); redraw(); } }, 'rowbtn'),
mkBtn('↓', () => { if (r < rows.length - 1) { rows.splice(r + 1, 0, rows.splice(r, 1)[0]); redraw(); } }, 'rowbtn'),
mkBtn('✕', () => { rows.splice(r, 1); redraw(); }, 'rowbtn danger'),
);
tr.appendChild(axis);
row.forEach((v, c) => {
const td = el('td');
td.appendChild(cell(v, (nv) => { rows[r][c] = nv; }));
tr.appendChild(td);
});
table.appendChild(tr);
});
body.appendChild(table);
body.appendChild(hint('Cells take the same inline markup as prose: <code>`code`</code>, <code>**bold**</code>, <code>[[Tab]]</code>.'));
$('ovDone').disabled = false;
foot.append(
mkBtn('+ row', () => { rows.push(new Array(width()).fill('')); redraw(); }),
check('Header row', hasHeader, (v) => { hasHeader = v; redraw(); }),
el('span', 'grow'),
mkBtn('Import CSV…', () => { mode = 'csv'; redraw(); }),
);
};
/* ---------- CSV import, previewed before it touches the grid ---------- */
const buildCsv = (body, redraw, foot) => {
$('ovDone').disabled = true; // nothing commits from here
body.appendChild(hint(
'Choose a file or paste the text. Nothing is applied until you press '
+ '<b>Use this data</b>, and even then it only fills the grid — the table '
+ 'itself is not changed until you press Done.'));
const bar = el('div', 'rowbtns');
bar.appendChild(mkBtn('Choose a file…', () => pick('.csv,.tsv,.txt', async (f) => {
csv.raw = (await f.text()).replace(/^\uFEFF/, '');
csv.name = f.name;
csv.delim = sniffDelimiter(csv.raw);
redraw();
})));
if (csv.name) {
const tag = el('span');
tag.style.cssText = 'font-size:.75rem;color:var(--ink-faint);align-self:center';
tag.textContent = csv.name;
bar.appendChild(tag);
}
body.appendChild(bar);
const paste = el('textarea');
paste.rows = 5;
paste.value = csv.raw;
paste.placeholder = 'Tool,What it is for\nwget,Pulling a file from a web address';
paste.addEventListener('change', () => {
csv.raw = paste.value;
csv.delim = sniffDelimiter(csv.raw);
redraw();
});
body.appendChild(wrap('CSV text', paste));
const opts = el('div', 'rowbtns');
opts.append(
choose('Separator', csv.delim,
[[',', 'Comma ,'], [';', 'Semicolon ;'], ['\t', 'Tab'], ['|', 'Pipe |']],
(v) => { csv.delim = v; redraw(); }),
check('First row is the header', csv.header, (v) => { csv.header = v; redraw(); }),
);
body.appendChild(opts);
const grid = csv.raw.trim() ? parseCSV(csv.raw, csv.delim) : [];
if (grid.length) {
const w = Math.max(...grid.map((r) => r.length));
body.appendChild(hint(`Preview — ${grid.length} row${grid.length === 1 ? '' : 's'} × ${w} column${w === 1 ? '' : 's'}`
+ (grid.length > 12 ? ', first 12 shown' : '')));
const t = el('table', 'grid');
grid.slice(0, 12).forEach((row, i) => {
const tr = el('tr', csv.header && i === 0 ? 'hdr' : '');
for (let c = 0; c < w; c += 1) {
const td = el('td');
const box = el('div');
box.style.cssText = 'padding:.3rem .45rem;border:1px solid var(--line);'
+ 'border-radius:3px;font-size:.76rem;min-width:7rem;'
+ (csv.header && i === 0 ? 'font-weight:700;' : '');
box.textContent = row[c] === undefined ? '' : row[c];
td.appendChild(box);
tr.appendChild(td);
}
t.appendChild(tr);
});
body.appendChild(t);
} else if (csv.raw.trim()) {
body.appendChild(hint('Nothing parsed — try a different separator.', true));
}
foot.append(
mkBtn('Back to the table', () => { mode = 'grid'; redraw(); }),
el('span', 'grow'),
mkBtn(`Use this data${grid.length ? ` (${grid.length} rows)` : ''}`, () => {
if (!grid.length) { flash('nothing to import'); return; }
const w = Math.max(...grid.map((r) => r.length));
const pad = (r) => { const c2 = r.slice(); while (c2.length < w) c2.push(''); return c2; };
if (csv.header) {
cols = pad(grid[0]);
rows = grid.slice(1).map(pad);
hasHeader = true;
} else {
cols = new Array(w).fill('');
rows = grid.map(pad);
hasHeader = false;
}
mode = 'grid';
redraw();
}, grid.length ? 'primary' : ''),
);
};
openOverlay('Table',
(body, redraw, foot) => (mode === 'csv' ? buildCsv(body, redraw, foot) : buildGrid(body, redraw, foot)),
() => {
snapshot();
normalise();
node.columns = hasHeader ? cols.slice() : new Array(width()).fill('');
node.rows = rows.map((r) => r.slice());
});
}
/* ---------- CSV ---------- */
/** RFC 4180: quoted fields, doubled quotes, separators and newlines inside them. */
export function parseCSV(text, delim) {
const rows = [];
let row = [], field = '', quoted = false, i = 0;
while (i < text.length) {
const c = text[i];
if (quoted) {
if (c === '"') {
if (text[i + 1] === '"') { field += '"'; i += 2; continue; }
quoted = false; i += 1; continue;
}
field += c; i += 1; continue;
}
if (c === '"' && field === '') { quoted = true; i += 1; continue; }
if (c === delim) { row.push(field.trim()); field = ''; i += 1; continue; }
if (c === '\r') { i += 1; continue; }
if (c === '\n') { row.push(field.trim()); rows.push(row); row = []; field = ''; i += 1; continue; }
field += c; i += 1;
}
if (field !== '' || row.length) { row.push(field.trim()); rows.push(row); }
return rows.filter((r) => r.some((c) => c !== ''));
}
/** Whichever separator yields the most columns, consistently. */
export function sniffDelimiter(text) {
const sample = text.split('\n').slice(0, 8).join('\n');
let best = ',', bestScore = 0;
[',', ';', '\t', '|'].forEach((d) => {
const g = parseCSV(sample, d);
if (!g.length) return;
const widths = g.map((r) => r.length);
const w = Math.max(...widths);
// reward width, but only when the rows agree on it
const consistent = widths.filter((x) => x === w).length / widths.length;
const score = w > 1 ? w * consistent : 0;
if (score > bestScore) { bestScore = score; best = d; }
});
return best;
}
/* ---------- capture a real terminal session ---------- */
function openCapture(node) {
let parsed = null;
const build = (body, redraw) => {
body.appendChild(hint(
'Run the commands in a real terminal — your ssh session on <code>ibilinux1</code>, ' +
'or any shell — then copy the whole thing, prompts and all, and paste it below. ' +
'Prompts, commands, output and errors are sorted out automatically; anything ' +
'mis-sorted can be fixed afterwards in the Lines box.'));
const paste = el('textarea');
paste.rows = 12;
paste.placeholder = 'a26benul@hs.local@ibilinux1:~$ ls -a\n. .. .bashrc .config R\na26benul@hs.local@ibilinux1:~$ ls -z\nls: invalid option -- \'z\'';
body.appendChild(wrap('Pasted session', paste));
const out = el('div');
body.appendChild(out);
const preview = () => {
parsed = parseSession(paste.value);
out.innerHTML = '';
if (!paste.value.trim()) return;
const counts = parsed.text.split('\n').reduce((a, l) => {
const k = l.startsWith('$ ') ? 'commands' : l.startsWith('! ') ? 'errors' : 'output';
a[k] = (a[k] || 0) + 1;
return a;
}, {});
out.appendChild(hint(
`Detected prompt <code>${parsed.prompt.replace(/[&<>]/g, '')}</code> · ` +
`${counts.commands || 0} commands, ${counts.errors || 0} errors, ${counts.output || 0} output lines.`));
const pv = el('textarea');
pv.rows = 10;
pv.value = parsed.text;
pv.addEventListener('input', () => { parsed.text = pv.value; });
out.appendChild(wrap('How it will be stored', pv));
};
paste.addEventListener('input', preview);
setTimeout(() => paste.focus(), 30);
};
openOverlay('Capture a session', build, () => {
if (!parsed || !parsed.text.trim()) return;
snapshot();
node.lines = textToTerm(parsed.text, parsed.prompt);
});
}