/* ===================================================================== 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 = `${item.label}${item.hint}`; 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 = `
Nothing selected.
Click a block on the canvas to edit it, or drag a component in from the left.
New blocks land inside whatever is selected.
{{part:${node.id}}} — 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. $ marks a typed command, ! 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`, [[Tab]] for keys, **bold**, ' +
'*italic*, [text](url), {{part:id}}. 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 = 'Documentmeta';
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 ·.'),
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`, **bold**, [[Tab]].'));
$('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 '
+ 'Use this data, 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 ibilinux1, ' +
'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 ${parsed.prompt.replace(/[&<>]/g, '')} · ` +
`${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);
});
}