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
273 lines
9.2 KiB
JavaScript
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;
|
|
}
|