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