From c18510b3c31f3e795307d4d338e1404e7c156f44 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 11 Aug 2026 19:59:59 +0200 Subject: [PATCH] Import the exercise builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Ed5kU3HfZq9dfj7EYj5J2v --- README.md | 59 ++ app/app.js | 1350 ++++++++++++++++++++++++++++++++++++++++++++++ app/builder.html | 247 +++++++++ app/document.js | 88 +++ app/import.js | 232 ++++++++ app/inline.js | Bin 0 -> 3028 bytes app/logo.txt | 1 + app/model.js | 272 ++++++++++ app/render.js | 224 ++++++++ app/sheet.css | 683 +++++++++++++++++++++++ app/sheet.js | 85 +++ 11 files changed, 3241 insertions(+) create mode 100644 README.md create mode 100644 app/app.js create mode 100644 app/builder.html create mode 100644 app/document.js create mode 100644 app/import.js create mode 100644 app/inline.js create mode 100644 app/logo.txt create mode 100644 app/model.js create mode 100644 app/render.js create mode 100644 app/sheet.css create mode 100644 app/sheet.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..6339227 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# Exercise Builder — hosted copy + +The drag-and-drop workspace for building BI122G exercise sheets, served by the +Workshop at: + + /projects/exercise-builder landing page + /projects/exercise-builder/app the builder itself + +Open to everyone; no sign-in. + +## What is here + + app/ the builder — plain HTML, CSS, and ES modules. No build step. + +`app/` is the whole application. It runs entirely in the browser: work in +progress lives in the visitor's `localStorage`, and finished sheets and project +files leave as downloads. **Nothing is written on the server**, and there is no +endpoint that could write anything. That is what makes it safe to host without a +login. + +Note this differs from the local development setup in the course folder, where +`edit_server.py` accepts `POST /__save` and writes sheets straight to disk. That +server is not deployed here, and must not be — reachable without a login it +would be a write primitive for anyone who could load the page. + +## How it is served + +`workshop/src/routes/projects/exercise-builder/app/[...file]/+server.ts` reads +this directory at request time. Consequences worth knowing: + +- **Updating the builder does not need a site rebuild.** Sync new files here and + a reload picks them up (responses are `no-cache` with an mtime-based ETag). + A rebuild is only needed when the landing page or the route itself changes. +- Only recognised asset extensions are reachable, and paths are contained to + this directory — so nothing outside `app/` can be requested. +- `.js` is served as `text/javascript`, without which browsers refuse ES + modules outright. +- The route injects `` into the + served HTML, so the builder's relative URLs resolve whether or not the visitor + typed a trailing slash. + +Override the location with `EXERCISE_BUILDER_ROOT` in the workshop `.env` if it +ever moves. + +## Updating + +Edit the files here. There is no build step and no sync — a reload picks the +change up, for the reasons above. + +## Source of truth + +**This directory.** The builder was developed in the BI122G course folder on +the workstation (`/Muspelheim/Employment/HiS/Teaching/BI122G`) and pushed here +by `deploy_builder.sh`. That arrangement has ended and this installation is now +canonical: **do not run `deploy_builder.sh` again**, as it would overwrite this +directory with the older course-folder copy. + +The course folder still holds `test.mjs`, a Node test harness that was never +deployed here — it needs `linkedom` and is no use in a browser. diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..f4e935f --- /dev/null +++ b/app/app.js @@ -0,0 +1,1350 @@ +/* ===================================================================== + 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); + }); +} diff --git a/app/builder.html b/app/builder.html new file mode 100644 index 0000000..4c37997 --- /dev/null +++ b/app/builder.html @@ -0,0 +1,247 @@ + + + + + +BI122G · Exercise builder + + + + + +
+ Exercise builder + + + + + + + + + + + + + + ready +
+ +
+
+
+
+
+ +
+
+ + + + + + + + diff --git a/app/document.js b/app/document.js new file mode 100644 index 0000000..78eb87b --- /dev/null +++ b/app/document.js @@ -0,0 +1,88 @@ +/* ===================================================================== + Assembling and reading whole sheet files. + + The preview iframe and the exported file are built from the same + renderer, so what you arrange on the canvas is what lands on disk. + The only difference is that the preview carries editing chrome and + omits the click-to-copy script, because on the canvas a click selects. + ===================================================================== */ + +import { renderBody } from './render.js'; + +let cache = null; + +export async function assets() { + if (!cache) { + const [css, js, logo] = await Promise.all([ + fetch('./sheet.css').then((r) => r.text()), + fetch('./sheet.js').then((r) => r.text()), + fetch('./logo.txt').then((r) => r.text()).catch(() => ''), + ]); + cache = { css, js, logo: logo.trim() }; + } + return cache; +} + +const esc = (s) => String(s).replace(/[&<>]/g, + (c) => ({ '&': '&', '<': '<', '>': '>' }[c])); + +function title(doc) { + const code = (doc.meta.courseCode || '').trim() || 'Exercise'; + return `${code} · ${doc.meta.exercise} · ${doc.meta.title}`; +} + +/** A complete, self-contained sheet — what Export writes. */ +export function wholeFile(doc, { css, js }) { + return [ + '', + '', + '', + '', + '', + `${esc(title(doc))}`, + '', + // A reader who overrode their system setting has to have it applied + // before the first paint, or every load of a dark sheet starts with a + // white flash. That rules out sheet.js, which runs at the end of the + // body — so the read happens here and the switch itself is still built + // down there. The key is shared with sheet.js; keep the two in step. + '