/* ===================================================================== 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.

`; // (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 = `${labelFor(node.kind, node)}${node.kind}`; 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 {{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); }); }