- Modular block files: split into blocks-algo, blocks-data, blocks-proc - New modules: sections.js (labelled canvas areas), proc-browser.js (procedure tree modal), lib-loader.js (lazy library loading), templates.js (project templates), errno-data.js (errno table) - Test infrastructure: browser test suite (28 tests) and Node pure-function tests (75 tests) - Warning system: unified .block-warn for disconnected inputs, unconnected error hooks, and zero-exit-in-error-path - Error path highlighting: red flow paths and block borders for error-only subgraphs - Input hooks: hidden by default, shown during linking; outputs always at bottom - Block editor modal: pencil icon, colour picker for accent, compact field labels - Doc generation: standalone node script extracts JSDoc -> docs/API.md (124 declarations) - Persistence: save/restore sections, errno selection, block colours - Fixes: _segmentsIntersect bug, WCEnd crash from closest(this), drawpath guard on detached elements
275 lines
10 KiB
JavaScript
275 lines
10 KiB
JavaScript
// ── Workspace persistence (localStorage) ──────────────────────────────────────
|
|
//
|
|
// Serialises and deserialises the full workspace state — all blocks in all
|
|
// three tab panes, their positions and data, plus SVG connection topology.
|
|
//
|
|
// Depends on: dom.js ($, $id, svgNamespace, STORAGE_KEY)
|
|
// helpers.js (refreshAll, splitValueAndUnit)
|
|
// draw.js (drawpath)
|
|
//
|
|
|
|
|
|
/**
|
|
* Reads the position and type-specific data from a single block element.
|
|
* Delegates to the block's saveData() method for type-specific fields.
|
|
* @param {HTMLElement} block - a WCBlock or WCProgram child element
|
|
* @returns {{ x: number, y: number, ... }}
|
|
*/
|
|
function saveBlockData(block) {
|
|
const data = {
|
|
x: parseInt(block.style.left) || 0,
|
|
y: parseInt(block.style.top) || 0
|
|
};
|
|
Object.assign(data, block.saveData());
|
|
return data;
|
|
}
|
|
|
|
|
|
/**
|
|
* Serialises the entire workspace to localStorage under STORAGE_KEY.
|
|
* Captures all blocks from algorithm, measurements, and data panes,
|
|
* plus every SVG connection path.
|
|
*/
|
|
function saveWorkspace() {
|
|
const prog = $id("main-program");
|
|
const dataArea = $id("data-area");
|
|
const varArea = $id("variable-area");
|
|
const connectionsSvg = $id("connections");
|
|
if (!prog || !dataArea || !varArea || !connectionsSvg) {
|
|
showError("Cannot save: missing core UI elements");
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
version: 1,
|
|
counter: prog.counter,
|
|
blocks: { algorithm: [], measurements: [], data: [], procedures: [] },
|
|
connections: [],
|
|
sections: typeof saveSections === "function" ? saveSections() : []
|
|
};
|
|
|
|
for (const block of prog.children) {
|
|
if (!block.saveData) continue;
|
|
state.blocks.algorithm.push({
|
|
id: block.id, tag: block.tagName.toLowerCase(),
|
|
data: saveBlockData(block)
|
|
});
|
|
}
|
|
for (const block of dataArea.children) {
|
|
if (!block.saveData) continue;
|
|
state.blocks.measurements.push({
|
|
id: block.id, tag: block.tagName.toLowerCase(),
|
|
data: saveBlockData(block)
|
|
});
|
|
}
|
|
for (const block of varArea.children) {
|
|
if (!block.saveData) continue;
|
|
state.blocks.data.push({
|
|
id: block.id, tag: block.tagName.toLowerCase(),
|
|
data: saveBlockData(block)
|
|
});
|
|
}
|
|
for (const block of document.querySelectorAll(".lib-pane .procedures-area > [data-block]")) {
|
|
state.blocks.procedures.push({
|
|
id: block.id, tag: block.tagName.toLowerCase(),
|
|
data: saveBlockData(block)
|
|
});
|
|
}
|
|
for (const path of connectionsSvg.querySelectorAll("path.flow, path.flow-data")) {
|
|
state.connections.push({
|
|
fromBlock: path.fromElement ? path.fromElement.id : "",
|
|
fromHook: path.fromHook ? (path.fromHook.id || null) : null,
|
|
toBlock: path.toElement ? path.toElement.id : "",
|
|
toHook: path.toHook ? (path.toHook.id || null) : null
|
|
});
|
|
}
|
|
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
|
}
|
|
|
|
|
|
/**
|
|
* Deep-clones a block <template> by tag name and restores position + data onto it.
|
|
* Delegates to the element's restoreData() method for type-specific fields.
|
|
* @param {{ id: string, tag: string, data: object }} info - saved block descriptor
|
|
* @returns {HTMLElement|null} the restored element, or null for unknown tags
|
|
*/
|
|
function restoreBlock(info) {
|
|
const reg = BLOCK_REGISTRY[info.tag];
|
|
if (!reg) return null;
|
|
if (!info.data) { showError("Missing data for block " + info.id); return null; }
|
|
|
|
const tmpl = document.querySelector(reg.template);
|
|
if (!tmpl) { showError("Missing template for " + info.tag); return null; }
|
|
const frag = document.importNode(tmpl.content, true);
|
|
const el = frag.firstElementChild;
|
|
if (!el) { showError("Empty template for " + info.tag); return null; }
|
|
el.id = info.id;
|
|
/* Only set position for algorithm tab blocks (they have flow connections) */
|
|
if (info.data.x !== undefined) el.style.left = info.data.x + "px";
|
|
if (info.data.y !== undefined) el.style.top = info.data.y + "px";
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* Restores a group of block descriptors into a container in a single pass.
|
|
* Creates each block, appends it, then calls restoreData on all of them.
|
|
* @param {{ id: string, tag: string, data: object }[]} infos - saved block descriptors
|
|
* @param {HTMLElement} container - parent element to append blocks into
|
|
*/
|
|
function restoreGroup(infos, container) {
|
|
const els = [];
|
|
for (const info of infos) {
|
|
const el = restoreBlock(info);
|
|
if (el) { container.appendChild(el); els.push({ el, data: info.data || {} }); }
|
|
}
|
|
for (const { el, data } of els) el.restoreData(data);
|
|
}
|
|
|
|
|
|
/**
|
|
* Deserialises the workspace from localStorage and reconstructs all blocks,
|
|
* connections, and internal state. Existing user blocks are removed first.
|
|
*/
|
|
function loadWorkspace() {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return;
|
|
let state;
|
|
try { state = JSON.parse(raw); } catch { showError("Could not load workspace: saved data is corrupted."); return; }
|
|
|
|
const prog = $id("main-program");
|
|
const dataArea = $id("data-area");
|
|
const varArea = $id("variable-area");
|
|
const connectionsSvg = $id("connections");
|
|
|
|
if (!prog || !dataArea || !varArea || !connectionsSvg) {
|
|
showError("Missing core UI elements for workspace load");
|
|
return;
|
|
}
|
|
|
|
/* ── 1. Tear down existing state ── */
|
|
_clearWorkspace({ keepStorage: true });
|
|
|
|
prog.counter = state.counter;
|
|
|
|
/* ── 2. Restore measurements (types + units) ── */
|
|
restoreGroup(state.blocks.measurements, dataArea);
|
|
const firstUnit = dataArea.querySelector("wc-unit");
|
|
if (firstUnit) {
|
|
let unitHeader = dataArea.querySelector(".list-header");
|
|
if (!unitHeader) {
|
|
unitHeader = document.createElement("div");
|
|
unitHeader.className = "list-header";
|
|
unitHeader.style.borderTop = "2px solid var(--border)";
|
|
unitHeader.textContent = "Units \u2014 Fullname \u00a0 Type \u00a0 Suffix \u00a0 SI Factor \u00a0 Resolved";
|
|
}
|
|
dataArea.insertBefore(unitHeader, firstUnit);
|
|
}
|
|
|
|
/* ── 3. Restore procedures — BEFORE algorithm (WCProcCall depends on them) ── */
|
|
const libMap = {};
|
|
const procEls = [];
|
|
const paneProcedures = $id("pane-procedures");
|
|
const procTabBar = document.querySelector(".lib-tab-bar");
|
|
for (const info of state.blocks.procedures) {
|
|
const lib = (info.data && info.data.library) || "unknown";
|
|
if (!libMap[lib]) {
|
|
markLibLoaded(lib);
|
|
const tab = document.createElement("button");
|
|
tab.className = "lib-tab";
|
|
tab.dataset.libName = lib;
|
|
tab.textContent = lib;
|
|
tab.addEventListener("click", () => switchLibTab(lib));
|
|
if (procTabBar) procTabBar.appendChild(tab);
|
|
|
|
const pane = document.createElement("div");
|
|
pane.className = "lib-pane";
|
|
pane.dataset.libName = lib;
|
|
const hdr = document.createElement("div");
|
|
hdr.className = "list-header";
|
|
hdr.textContent = "Procedure \u00a0 Inputs \u2192 Outputs";
|
|
pane.appendChild(hdr);
|
|
const pa = document.createElement("div");
|
|
pa.className = "procedures-area";
|
|
pane.appendChild(pa);
|
|
if (paneProcedures) paneProcedures.appendChild(pane);
|
|
|
|
libMap[lib] = pa;
|
|
}
|
|
const el = restoreBlock(info);
|
|
if (el) { libMap[lib].appendChild(el); procEls.push({ el, data: info.data || {} }); }
|
|
}
|
|
for (const { el, data } of procEls) el.restoreData(data);
|
|
/* Activate first library tab */
|
|
const firstTab = document.querySelector(".lib-tab");
|
|
if (firstTab) switchLibTab(firstTab.dataset.libName);
|
|
|
|
/* ── 4. Restore algorithm blocks (reposition start/end, recreate others) ── */
|
|
const algoEls = [];
|
|
for (const info of state.blocks.algorithm) {
|
|
if (info.id === "main-start" || info.id === "main-end") {
|
|
const el = $id(info.id);
|
|
if (el) {
|
|
el.style.left = info.data.x + "px";
|
|
el.style.top = info.data.y + "px";
|
|
}
|
|
} else {
|
|
const el = restoreBlock(info);
|
|
if (el) { prog.appendChild(el); algoEls.push({ el, data: info.data || {} }); }
|
|
}
|
|
}
|
|
for (const { el, data } of algoEls) el.restoreData(data);
|
|
|
|
/* ── 5. Restore data (datum / variable blocks) ── */
|
|
restoreGroup(state.blocks.data, varArea);
|
|
|
|
/* ── 6. Restore SVG connections ── */
|
|
for (const conn of state.connections) {
|
|
const fromBlock = $id(conn.fromBlock);
|
|
const toBlock = $id(conn.toBlock);
|
|
if (!fromBlock || !toBlock) continue;
|
|
|
|
const fromHook = conn.fromHook
|
|
? (fromBlock.querySelector('[id="' + conn.fromHook + '"]') || fromBlock.querySelector('[data-direction="out"]') || fromBlock.querySelector(".hook"))
|
|
: (fromBlock.querySelector('[data-direction="out"]') || fromBlock.querySelector(".hook"));
|
|
const toHook = conn.toHook
|
|
? (toBlock.querySelector('[id="' + conn.toHook + '"]') || toBlock.querySelector('[data-direction="in"]') || toBlock.querySelector(".hook"))
|
|
: (toBlock.querySelector('[data-direction="in"]') || toBlock.querySelector(".hook"));
|
|
if (!fromHook || !toHook) continue;
|
|
|
|
const linkType = fromHook.dataset.linkType || "logic";
|
|
const path = document.createElementNS(svgNamespace, "path");
|
|
path.dataset.linkType = linkType;
|
|
path.classList.add(linkType === "data" ? "flow-data" : "flow");
|
|
path.toElement = toBlock;
|
|
path.fromElement = fromBlock;
|
|
path.fromHook = fromHook;
|
|
path.toHook = toHook;
|
|
toBlock.wcForeFlow.push(path);
|
|
fromBlock.wcNextFlow.push(path);
|
|
connectionsSvg.appendChild(path);
|
|
drawpath(path);
|
|
}
|
|
|
|
/* ── 7. Restore logic sections ── */
|
|
if (state.sections && typeof restoreSections === "function") {
|
|
restoreSections(state.sections);
|
|
}
|
|
|
|
/* ── 8. Refresh dropdowns & SI displays (after all blocks + connections exist) ── */
|
|
refreshAll();
|
|
if (dataArea) for (const u of dataArea.querySelectorAll("wc-unit")) u.updateSiDisplay();
|
|
if (varArea) for (const d of varArea.querySelectorAll("wc-scalar")) d.updateSiDisplay();
|
|
}
|
|
|
|
|
|
/**
|
|
* Clears the saved workspace (removes localStorage entry) and reloads the page,
|
|
* returning the app to its pristine default state.
|
|
*/
|
|
function newWorkspace() {
|
|
if (typeof showNewProjectModal === "function") {
|
|
showNewProjectModal();
|
|
}
|
|
}
|