Files
ryan c18510b3c3 Import the exercise builder
The drag-and-drop workspace for building BI122G exercise sheets, served
by the Workshop at /projects/exercise-builder. Plain HTML, CSS and ES
modules; no build step. It runs entirely in the browser — work in
progress lives in localStorage and finished sheets leave as downloads,
so nothing is written on the server.

This directory is canonical. It began as a deployment target for
deploy_builder.sh syncing from the BI122G course folder, and that
arrangement has ended; see the README.

Includes the sheet's light/dark switch: the stylesheet already carried
both themes and followed the reader's system setting, so this adds the
control that overrides it — a corner toggle built by sheet.js, applied
before first paint by a head script the exporter writes, and hidden in
print. The builder's toolbar previews the same attribute on the canvas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ed5kU3HfZq9dfj7EYj5J2v
2026-08-11 19:59:59 +02:00

273 lines
9.2 KiB
JavaScript

/* =====================================================================
Exercise builder — document model
One JSON tree is the source of truth. Every container holds an ordered
`children` array, which is what makes drag-and-drop a single operation:
move a node from one children array into another.
Node kinds
part major step track: "core" | "side"
task minor step container; only ever inside a part
prose paragraph inline markup, see inline.js
cmd command box
terminal transcript typed lines
protip keystroke tip
sidequest LEGACY only inline optional block; see accepts()
callout emphasis block container
note quiet aside container
table data table
list bullet list
===================================================================== */
export const CONTAINERS = new Set(['part', 'task', 'sidequest', 'callout', 'note']);
export const PALETTE = [
// The sidebar mirrors the shape of a sheet: the three categories from the
// legend first, then the step that lives inside them, then the pieces.
{ kind: 'part', preset: { track: 'core' }, group: 'Structure', label: 'Core', hint: 'An examinable section' },
{ kind: 'part', preset: { track: 'side' }, group: 'Structure', label: 'Side quest', hint: 'An optional section' },
{ kind: 'protip', group: 'Structure', label: 'Protip', hint: 'A quick tip' },
{ kind: 'task', group: 'Structure', label: 'Task', hint: 'A numbered step' },
{ kind: 'callout', group: 'Containers', label: 'Callout', hint: 'Emphasised block' },
{ kind: 'note', group: 'Containers', label: 'Note', hint: 'Quiet aside' },
{ kind: 'prose', group: 'Content', label: 'Prose', hint: 'A paragraph' },
{ kind: 'list', group: 'Content', label: 'Bullet list', hint: 'Plain bullets' },
{ kind: 'table', group: 'Content', label: 'Table', hint: 'Rows and columns' },
{ kind: 'cmd', group: 'Content', label: 'Command', hint: 'Click-to-copy box' },
{ kind: 'terminal', group: 'Content', label: 'Terminal', hint: 'Session transcript' },
];
/** The wording of the three categories. One source of truth. */
export const DEFAULT_LEGEND = {
core: 'Material from the lecture. This is what is examinable. You should complete all of these sections.',
side: 'Not examinable. Useful Information worth knowing, but safe to skip if you are short on time.',
protip: 'A quick tip to save you time',
};
let seq = 0;
export function uid(prefix = 'n') {
seq += 1;
return `${prefix}${Date.now().toString(36)}${seq.toString(36)}`;
}
/* ---------- construction ---------- */
export function blank(kind) {
const id = uid(kind[0]);
switch (kind) {
case 'part':
return { id, kind, track: 'core', title: 'New part', children: [] };
case 'task':
return { id, kind, children: [blank('prose')] };
case 'prose':
return { id, kind, text: 'New paragraph.', variant: null };
case 'cmd':
return { id, kind, command: 'pwd' };
case 'terminal':
// a line is a list of typed segments, because real transcripts mix
// prompt, command and output on one line
return { id, kind, caption: '', lines: [
[{ role: 'p', text: '$' }, { role: null, text: ' ' }, { role: 'c', text: 'pwd' }],
[{ role: 'o', text: '/home/you' }],
]};
case 'protip':
return { id, kind, keys: ['Tab'], label: 'Protip', text: 'What this saves you.', emphasis: false };
case 'sidequest':
return { id, kind, summary: 'Optional extra', children: [blank('prose')] };
case 'callout':
return { id, kind, heading: 'Heading', children: [blank('prose')] };
case 'note':
return { id, kind, children: [blank('prose')] };
case 'table':
return { id, kind, columns: ['Column', 'Column'], rows: [['', '']] };
case 'list':
return { id, kind, items: ['First item'] };
default:
throw new Error(`unknown kind: ${kind}`);
}
}
export function emptyDoc() {
return {
meta: {
courseCode: 'BI122G',
courseName: 'Introduction to Computational Tools for Bioinformatics',
term: 'Autumn 2026',
exercise: 'Exercise 1',
title: 'Untitled',
standfirst: 'In this exercise we will…',
logo: '',
legendText: { ...DEFAULT_LEGEND },
},
intro: [],
parts: [],
closing: [],
};
}
/* ---------- traversal ---------- */
/** Every array of nodes in the document, keyed so a node can be located. */
export function walk(doc, visit) {
const lists = [
{ list: doc.intro, owner: null, field: 'intro' },
{ list: doc.parts, owner: null, field: 'parts' },
{ list: doc.closing, owner: null, field: 'closing' },
];
while (lists.length) {
const { list, owner, field } = lists.shift();
list.forEach((node, index) => {
visit(node, list, index, owner, field);
if (Array.isArray(node.children)) {
lists.push({ list: node.children, owner: node, field: 'children' });
}
});
}
}
export function find(doc, id) {
let hit = null;
walk(doc, (node, list, index, owner) => {
if (node.id === id) hit = { node, list, index, owner };
});
return hit;
}
/** Would moving `id` into `targetList` put a node inside itself? */
function wouldOrphan(doc, id, targetList) {
const found = find(doc, id);
if (!found) return true;
let inside = false;
const scan = (node) => {
if (node.children === targetList) inside = true;
(node.children || []).forEach(scan);
};
scan(found.node);
return inside;
}
/* ---------- mutation ---------- */
export function insert(list, node, index) {
list.splice(index == null ? list.length : index, 0, node);
return node;
}
export function remove(doc, id) {
const found = find(doc, id);
if (!found) return null;
found.list.splice(found.index, 1);
return found.node;
}
export function move(doc, id, targetList, index) {
if (wouldOrphan(doc, id, targetList)) return false;
const found = find(doc, id);
if (!found) return false;
const sameList = found.list === targetList;
found.list.splice(found.index, 1);
let at = index == null ? targetList.length : index;
if (sameList && found.index < at) at -= 1;
targetList.splice(at, 0, found.node);
return true;
}
export function nudge(doc, id, delta) {
const found = find(doc, id);
if (!found) return false;
const to = found.index + delta;
if (to < 0 || to >= found.list.length) return false;
const [node] = found.list.splice(found.index, 1);
found.list.splice(to, 0, node);
return true;
}
export function duplicate(doc, id) {
const found = find(doc, id);
if (!found) return null;
const copy = reid(JSON.parse(JSON.stringify(found.node)));
found.list.splice(found.index + 1, 0, copy);
return copy;
}
/** A deep copy carrying fresh ids, subcomponents and all. */
export function cloneFresh(node) {
return reid(JSON.parse(JSON.stringify(node)));
}
function reid(node) {
node.id = uid(node.kind[0]);
(node.children || []).forEach(reid);
return node;
}
/* ---------- numbering ---------- */
/** Core parts number 1..n; side quests are not part of the sequence. */
export function partNumbers(doc) {
const map = new Map();
let n = 0;
doc.parts.forEach((part) => {
if (part.track !== 'side') { n += 1; map.set(part.id, n); }
});
return map;
}
/**
* Whether a node may legally accept a child of this kind.
*
* A side quest is a PART with track "side" — it never nests inside a core
* part. The `sidequest` node kind is retained only so that sheets written
* before that rule still import and render; it is absent from the palette,
* so nothing new can be built with it.
*/
export function accepts(parentKind, childKind) {
if (childKind === 'sidequest') return false; // legacy, never placeable
if (childKind === 'task') return parentKind === 'part';
if (childKind === 'part') return parentKind === 'root';
if (parentKind === 'root') return childKind === 'part';
return CONTAINERS.has(parentKind);
}
/**
* Fold away shapes from earlier versions of the model:
* a `tasks` wrapper node becomes its task children, sitting directly in the
* part. The <ol> is a rendering detail now, not something you build.
*/
export function migrate(doc) {
// a document saved before a field existed gets it filled in, so stale
// state in a browser cannot leave a category with no description
doc.meta = doc.meta || {};
const legend = { ...doc.meta.legendText };
Object.entries(DEFAULT_LEGEND).forEach(([k, v]) => {
if (!legend[k] || !String(legend[k]).trim()) legend[k] = v;
});
doc.meta.legendText = legend;
const flatten = (list) => {
for (let i = 0; i < list.length; i += 1) {
const node = list[i];
if (node.kind === 'tasks') {
list.splice(i, 1, ...(node.children || []));
i -= 1;
continue;
}
if (Array.isArray(node.children)) flatten(node.children);
}
};
[doc.intro, doc.parts, doc.closing].forEach((l) => Array.isArray(l) && flatten(l));
return doc;
}
/** Parts written as inline side quests, which the rule no longer allows. */
export function legacySideQuests(doc) {
const found = [];
walk(doc, (node, list, index, owner) => {
if (node.kind === 'sidequest') found.push({ node, owner });
});
return found;
}