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
This commit is contained in:
+272
@@ -0,0 +1,272 @@
|
||||
/* =====================================================================
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user