- 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
552 lines
18 KiB
JavaScript
552 lines
18 KiB
JavaScript
// ── Algorithm-tab block definitions ────────────────────────────────────────
|
|
//
|
|
// Blocks that live on the Algorithm (flowchart) canvas:
|
|
// WCStart, WCEnd, WCOutput, WCDecision, WCAssign, WCExpression, WCVarRef
|
|
//
|
|
// Each extends WCBlock (from wcblock.js) and provides generateOwnCode().
|
|
//
|
|
// Load order dependencies:
|
|
// wcblock.js → WCBlock base class
|
|
// helpers.js → findDataSource, resolveSourceName, escapeString, toCName,
|
|
// commentPrefix, refreshVarRefOptions
|
|
// dom.js → $id
|
|
// error.js → showError
|
|
|
|
|
|
// ── WCStart — program entry point ──────────────────────────────────────────
|
|
|
|
/** Program entry point. Cannot be deleted. */
|
|
class WCStart extends WCBlock {
|
|
delete() { showError("Cannot delete the start block."); }
|
|
}
|
|
|
|
|
|
// ── WCEnd — program exit ───────────────────────────────────────────────────
|
|
|
|
/** Program exit block with errno autocomplete. At least one must return 0. */
|
|
class WCEnd extends WCBlock {
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._errnoItems = _ERRNO_ITEMS;
|
|
this._selectedCode = 0;
|
|
this._el.input = this._require(".end-errno-input");
|
|
this._el.suggestions = this._require(".end-errno-suggestions");
|
|
this._el.desc = this._require(".end-desc-text");
|
|
|
|
this._el.input.addEventListener("input", () => this._onInput());
|
|
this._el.input.addEventListener("focus", () => { if (this._el.suggestions.children.length > 0) this._showSuggestions(); });
|
|
this._el.input.addEventListener("keydown", ev => this._onKeyDown(ev));
|
|
this._el.suggestions.addEventListener("mousedown", ev => {
|
|
const sug = ev.target.closest(".end-errno-suggestion");
|
|
if (sug) {
|
|
const code = parseInt(sug.dataset.code);
|
|
if (!isNaN(code)) this._selectByCode(code);
|
|
}
|
|
});
|
|
document.addEventListener("click", ev => {
|
|
if (!this.contains(ev.target)) this._hideSuggestions();
|
|
});
|
|
|
|
this._el.input.value = "0";
|
|
this._selectByCode(0);
|
|
}
|
|
|
|
_onInput() {
|
|
const q = this._el.input.value.trim().toLowerCase();
|
|
if (!q) {
|
|
this._renderSuggestions(this._errnoItems);
|
|
this._showSuggestions();
|
|
return;
|
|
}
|
|
const qCode = parseInt(q);
|
|
const matches = this._errnoItems.filter(item =>
|
|
(!isNaN(qCode) && item.code === qCode) ||
|
|
item.name.toLowerCase().includes(q) ||
|
|
item.desc.toLowerCase().includes(q)
|
|
);
|
|
this._renderSuggestions(matches);
|
|
this._showSuggestions();
|
|
}
|
|
|
|
_renderSuggestions(items) {
|
|
this._el.suggestions.innerHTML = "";
|
|
for (const item of items) this._addSuggestion(item);
|
|
}
|
|
|
|
_addSuggestion(item) {
|
|
const div = document.createElement("div");
|
|
div.className = "end-errno-suggestion";
|
|
div.dataset.code = item.code;
|
|
const codeSpan = document.createElement("span");
|
|
codeSpan.className = "end-suggestion-code";
|
|
codeSpan.textContent = item.code + " " + item.name;
|
|
div.appendChild(codeSpan);
|
|
const descSpan = document.createElement("span");
|
|
descSpan.className = "end-suggestion-desc";
|
|
descSpan.textContent = item.desc;
|
|
div.appendChild(descSpan);
|
|
this._el.suggestions.appendChild(div);
|
|
}
|
|
|
|
_showSuggestions() { this._el.suggestions.style.display = "block"; }
|
|
_hideSuggestions() { this._el.suggestions.style.display = "none"; }
|
|
|
|
_onKeyDown(ev) {
|
|
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
ev.preventDefault();
|
|
const items = [...this._el.suggestions.querySelectorAll(".end-errno-suggestion")];
|
|
let idx = items.findIndex(el => el.classList.contains("highlighted"));
|
|
if (ev.key === "ArrowDown") idx = Math.min(idx + 1, items.length - 1);
|
|
else idx = Math.max(idx - 1, 0);
|
|
items.forEach(el => el.classList.remove("highlighted"));
|
|
if (items[idx]) {
|
|
items[idx].classList.add("highlighted");
|
|
items[idx].scrollIntoView({ block: "nearest" });
|
|
}
|
|
}
|
|
if (ev.key === "Enter") {
|
|
const hl = this._el.suggestions.querySelector(".highlighted");
|
|
if (hl) {
|
|
ev.preventDefault();
|
|
this._selectByCode(parseInt(hl.dataset.code));
|
|
}
|
|
}
|
|
if (ev.key === "Escape") this._hideSuggestions();
|
|
}
|
|
|
|
_selectByCode(code) {
|
|
this._selectedCode = code;
|
|
const item = this._errnoItems.find(i => i.code === code);
|
|
if (item) {
|
|
this._el.input.value = code + " " + item.name;
|
|
this._el.desc.textContent = item.desc;
|
|
this.dataset.exitError = code !== 0 ? "true" : "";
|
|
} else {
|
|
this._el.input.value = code + " error";
|
|
this._el.desc.textContent = "unknown error";
|
|
this.dataset.exitError = "true";
|
|
}
|
|
this._hideSuggestions();
|
|
}
|
|
|
|
_enforceZeroEnd() {
|
|
if (this._selectedCode === 0) return;
|
|
const others = document.querySelectorAll("wc-end");
|
|
for (const other of others) {
|
|
if (other !== this && other._selectedCode === 0) return;
|
|
}
|
|
this._selectByCode(0);
|
|
}
|
|
|
|
_hasOtherZero() {
|
|
const others = document.querySelectorAll("wc-end");
|
|
for (const other of others) {
|
|
if (other !== this && other._selectedCode === 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
delete() {
|
|
if (this.id === "main-end") { showError("Cannot delete the default end block."); return; }
|
|
if (!this._hasOtherZero()) { showError("At least one end block must return 0."); return; }
|
|
super.delete();
|
|
setTimeout(() => {
|
|
for (const end of document.querySelectorAll("wc-end")) end._enforceZeroEnd();
|
|
}, 0);
|
|
}
|
|
|
|
_genStringFrees() {
|
|
const freed = new Set();
|
|
let code = "";
|
|
for (const el of document.querySelectorAll("#variable-area > [data-block]")) {
|
|
if (el.dataType === "string") {
|
|
const name = resolveSourceName(el);
|
|
if (name && !freed.has(name)) {
|
|
freed.add(name);
|
|
code += ` free(${name}.data);\n`;
|
|
}
|
|
}
|
|
}
|
|
return code;
|
|
}
|
|
|
|
generateOwnCode() {
|
|
const code = this._selectedCode;
|
|
if (code === 0) {
|
|
const frees = this._genStringFrees();
|
|
if (frees) return `\n${frees} return 0;\n`;
|
|
return "\n return 0;\n";
|
|
}
|
|
return `\n exit(${code});\n`;
|
|
}
|
|
|
|
saveData() { return { exitCode: this._selectedCode }; }
|
|
restoreData(d) {
|
|
this._selectByCode(d.exitCode !== undefined ? d.exitCode : 0);
|
|
}
|
|
}
|
|
|
|
function _buildErrnoItems() {
|
|
const items = [
|
|
{ code: 0, name: "success", desc: "success" },
|
|
{ code: 255, name: "error", desc: "generic error" },
|
|
];
|
|
for (const e of ERRNO_LIST) {
|
|
items.push({ code: e.code, name: e.name, desc: e.desc });
|
|
}
|
|
return items;
|
|
}
|
|
const _ERRNO_ITEMS = _buildErrnoItems();
|
|
|
|
|
|
// ── WCOutput — printf statement ────────────────────────────────────────────
|
|
|
|
/** Output description block. Provides inline documentation without generating code. */
|
|
class WCOutput extends WCBlock {
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._el.textarea = this.querySelector("textarea");
|
|
}
|
|
|
|
saveData() { return { textarea: this._el.textarea.value }; }
|
|
restoreData(d) { this.querySelector("textarea").value = d.textarea || ""; }
|
|
|
|
generateOwnCode() {
|
|
const msg = escapeString(this._el.textarea.value || "");
|
|
return `\nprintf("${msg}");\n`;
|
|
}
|
|
}
|
|
|
|
|
|
// ── WCDecision — conditional branch ────────────────────────────────────────
|
|
|
|
/** Conditional branch block; splits flow into yes/no branches and emits if/else C code. */
|
|
class WCDecision extends WCBlock {
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._el.boolIn = this.querySelector(".decision-data-in");
|
|
}
|
|
|
|
_getBoolName(block) {
|
|
return resolveSourceName(block) || "flag";
|
|
}
|
|
|
|
_branchDescendants(roots) {
|
|
const d = new Set(), q = [...roots];
|
|
for (const el of q) {
|
|
if (d.has(el)) continue;
|
|
d.add(el);
|
|
if (el instanceof WCDecision) continue;
|
|
for (const p of el.wcNextFlow) {
|
|
if (!d.has(p.toElement)) q.push(p.toElement);
|
|
}
|
|
}
|
|
return d;
|
|
}
|
|
|
|
_emitBranch(roots, stop, visited) {
|
|
const v = new Set(visited);
|
|
let c = "";
|
|
const walk = el => {
|
|
if (v.has(el) || stop.has(el)) return;
|
|
v.add(el);
|
|
if (el instanceof WCDecision) {
|
|
c += el.generateCode(v);
|
|
} else {
|
|
c += el.generateOwnCode();
|
|
for (const p of el.wcNextFlow) walk(p.toElement);
|
|
}
|
|
};
|
|
for (const el of roots) walk(el);
|
|
return c;
|
|
}
|
|
|
|
generateCode(visited = new Set()) {
|
|
const yesTargets = [], noTargets = [];
|
|
for (const path of this.wcNextFlow) {
|
|
const hook = path.fromHook;
|
|
if (!hook || hook.id === "hook-out1") {
|
|
yesTargets.push(path.toElement);
|
|
} else if (hook.id === "hook-out0") {
|
|
noTargets.push(path.toElement);
|
|
}
|
|
}
|
|
|
|
const yesAll = this._branchDescendants(yesTargets);
|
|
const noAll = this._branchDescendants(noTargets);
|
|
const shared = new Set([...yesAll].filter(el => noAll.has(el) && el !== this));
|
|
|
|
const yesCode = this._emitBranch(yesTargets, shared, visited);
|
|
const noCode = this._emitBranch(noTargets, shared, visited);
|
|
const sharedCode = this._emitBranch([...shared], new Set(), visited);
|
|
|
|
for (const el of yesAll) visited.add(el);
|
|
for (const el of noAll) visited.add(el);
|
|
|
|
const source = findDataSource(this._el.boolIn);
|
|
const cond = source ? this._getBoolName(source) : "0";
|
|
let out = "";
|
|
if (yesCode || noCode) {
|
|
if (yesCode) out += `if (${cond}) {\n${yesCode}}`;
|
|
if (noCode) {
|
|
out += yesCode
|
|
? ` else {\n${noCode}}`
|
|
: `if (!(${cond})) {\n${noCode}}`;
|
|
}
|
|
}
|
|
out += sharedCode;
|
|
return out ? `\n${out}\n` : "";
|
|
}
|
|
}
|
|
|
|
|
|
// ── WCAssign — variable assignment block ───────────────────────────────────
|
|
|
|
/** Variable assignment block; writes to scalar/array/vector/string targets with data hooks. */
|
|
class WCAssign extends WCBlock {
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._el.target = this.querySelector(".assign-target");
|
|
this._el.value = this.querySelector(".assign-value");
|
|
this._el.dataIn = this.querySelector(".assign-row .hook");
|
|
this._el.indexArea = this.querySelector(".assign-index-area");
|
|
this._el.indexInput = this.querySelector(".assign-index");
|
|
this._el.indexHook = this.querySelector(".assign-index-area .hook");
|
|
|
|
this._el.target.addEventListener("change", () => this._syncType());
|
|
setTimeout(() => {
|
|
refreshVarRefOptions();
|
|
this._syncType();
|
|
this._updateValueVisibility();
|
|
}, 0);
|
|
}
|
|
|
|
_updateValueVisibility() {
|
|
const hasConn = findDataSource(this._el.dataIn) !== null;
|
|
this._el.value.classList.toggle("hidden", hasConn);
|
|
const hasIdxConn = findDataSource(this._el.indexHook) !== null;
|
|
this._el.indexInput.classList.toggle("hidden", hasIdxConn);
|
|
}
|
|
|
|
_syncType() {
|
|
const id = this._el.target.value;
|
|
const target = id ? $id(id) : null;
|
|
const type = target ? target.dataType : "scalar";
|
|
this._el.dataIn.dataset.type = type;
|
|
this._el.dataIn.title = "expects " + type;
|
|
this._el.indexArea.style.display = type === "array" ? "flex" : "none";
|
|
}
|
|
|
|
_getSourceVarName(block) {
|
|
return resolveSourceName(block) || "x";
|
|
}
|
|
|
|
_genStringLiteral(name, raw) {
|
|
const escaped = escapeString(raw);
|
|
const len = raw.length;
|
|
return "\n" + [
|
|
`free(${name}.data);`,
|
|
`${name}.length = ${len};`,
|
|
`${name}.data = malloc(${len} + 1);`,
|
|
`memcpy(${name}.data, "${escaped}", ${len});`,
|
|
`${name}.data[${len}] = 0;`,
|
|
].join("\n") + "\n";
|
|
}
|
|
|
|
_genStringCopy(dst, src) {
|
|
return "\n" + [
|
|
`free(${dst}.data);`,
|
|
`${dst}.length = ${src}.length;`,
|
|
`${dst}.data = malloc(${src}.length + 1);`,
|
|
`memcpy(${dst}.data, ${src}.data, ${src}.length);`,
|
|
`${dst}.data[${dst}.length] = 0;`,
|
|
].join("\n") + "\n";
|
|
}
|
|
|
|
saveData() { return { target: this._el.target.value, value: this._el.value.value, index: this._el.indexInput.value }; }
|
|
restoreData(d) {
|
|
const tgt = this.querySelector(".assign-target");
|
|
tgt.dataset.restoredValue = d.target || "";
|
|
tgt.value = d.target || "";
|
|
this.querySelector(".assign-value").value = d.value || "";
|
|
this.querySelector(".assign-index").value = d.index || "0";
|
|
this._syncType();
|
|
this._updateValueVisibility();
|
|
}
|
|
|
|
generateOwnCode() {
|
|
const targetId = this._el.target.value;
|
|
if (!targetId) return "";
|
|
const target = $id(targetId);
|
|
if (!target) return "";
|
|
const targetName = resolveSourceName(target) || "x";
|
|
const targetType = target.dataType || "scalar";
|
|
|
|
const source = findDataSource(this._el.dataIn);
|
|
|
|
if (targetType === "array") {
|
|
const idxSource = findDataSource(this._el.indexHook);
|
|
const idxExpr = idxSource ? this._getSourceVarName(idxSource) : (this._el.indexInput.value || "0");
|
|
if (!source) {
|
|
const raw = this._el.value.value || "0";
|
|
return `\n${targetName}[${idxExpr}] = ${raw};\n`;
|
|
}
|
|
const srcName = this._getSourceVarName(source);
|
|
return `\n${targetName}[${idxExpr}] = ${srcName};\n`;
|
|
}
|
|
|
|
if (!source) {
|
|
const raw = this._el.value.value || "0";
|
|
if (targetType === "string") return this._genStringLiteral(targetName, raw);
|
|
return `\n${targetName} = ${raw};\n`;
|
|
}
|
|
|
|
const srcName = this._getSourceVarName(source);
|
|
if (targetType === "string" && source.dataType === "string") {
|
|
return this._genStringCopy(targetName, srcName);
|
|
}
|
|
return `\n${targetName} = ${srcName};\n`;
|
|
}
|
|
}
|
|
|
|
|
|
// ── WCVarRef — read-only variable reference with data output hook ──────────
|
|
|
|
/** Read-only variable reference with data output hook; syncs type from selected variable. */
|
|
class WCVarRef extends WCBlock {
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._el.select = this.querySelector(".varref-select");
|
|
this._el.select.addEventListener("change", () => {
|
|
this._syncType();
|
|
});
|
|
}
|
|
|
|
_syncType() {
|
|
const id = this._el.select.value;
|
|
const target = id ? $id(id) : null;
|
|
const hook = this.querySelector(".hook-out");
|
|
hook.dataset.type = target ? target.dataType : "scalar";
|
|
}
|
|
|
|
saveData() { return { selected: this._el.select.value }; }
|
|
restoreData(d) {
|
|
const sel = this.querySelector(".varref-select");
|
|
sel.dataset.restoredValue = d.selected || "";
|
|
sel.value = d.selected || "";
|
|
const id = sel.value;
|
|
const target = id ? $id(id) : null;
|
|
const hook = this.querySelector(".hook-out");
|
|
hook.dataset.type = target ? target.dataType : "scalar";
|
|
}
|
|
|
|
generateOwnCode() { return ""; }
|
|
}
|
|
|
|
|
|
// ── WCExpression — mathematical expression with MathJax rendering & data hooks ──
|
|
|
|
/** Math expression block with LaTeX rendering via MathJax and dynamic variable data hooks. */
|
|
class WCExpression extends WCBlock {
|
|
get dataType() {
|
|
const el = this._el?.latex || this.querySelector(".expr-latex");
|
|
const latex = el?.value || "";
|
|
return /[<>]|==|!=|\\(?:ge|le|ne|neq|geq|leq|land|lor|wedge|vee|neg|lnot)\b/.test(latex) ? "boolean" : "scalar";
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this._el.name = this.querySelector(".expr-name");
|
|
this._el.latex = this.querySelector(".expr-latex");
|
|
this._el.rendered = this.querySelector(".expr-rendered");
|
|
this._el.varhooks = this.querySelector(".expr-varhooks");
|
|
|
|
this._el.latex.addEventListener("input", () => this._renderFormula());
|
|
setTimeout(() => this._renderFormula(), 0);
|
|
}
|
|
|
|
saveData() { return { name: this._el.name.value, latex: this._el.latex.value }; }
|
|
restoreData(d) {
|
|
this._el.name.value = d.name || "";
|
|
this._el.latex.value = d.latex || "";
|
|
this._renderFormula();
|
|
}
|
|
|
|
_parseVariables(latex) {
|
|
const tokens = latex
|
|
.replace(/\\[a-zA-Z]+/g, "")
|
|
.replace(/[{}\[\]()]/g, " ")
|
|
.replace(/[\d+*\/^_=,;:!?<>|~`@#\$%&'-]/g, " ")
|
|
.split(/\s+/)
|
|
.filter(t => t.length > 0 && t.length <= 8)
|
|
.filter(t => ![
|
|
"sin","cos","tan","cot","sec","csc","arcsin","arccos","arctan",
|
|
"log","ln","lg","exp","sqrt","lim","inf","sum","int","prod","det",
|
|
"gcd","lcm","max","min","arg","mod","Re","Im","deg","Pr","var",
|
|
"alpha","beta","gamma","delta","epsilon","zeta","eta","theta",
|
|
"iota","kappa","lambda","mu","nu","xi","omicron","rho","sigma",
|
|
"tau","upsilon","phi","chi","psi","omega","varepsilon","vartheta",
|
|
"varpi","varphi","varrho","varsigma","varUpsilon"
|
|
].includes(t.toLowerCase()));
|
|
return [...new Set(tokens)];
|
|
}
|
|
|
|
_rebuildVarHooks(vars) {
|
|
this._el.varhooks.innerHTML = "";
|
|
for (const v of vars) {
|
|
const hook = document.createElement("button");
|
|
hook.className = "hook";
|
|
hook.id = this.id + "-vh-" + v;
|
|
hook.title = v;
|
|
hook.textContent = v;
|
|
hook.dataset.direction = "in";
|
|
hook.dataset.linkType = "data";
|
|
hook.dataset.type = "scalar";
|
|
hook.dataset.exit = "left";
|
|
this._setupHook(hook);
|
|
this._el.varhooks.appendChild(hook);
|
|
}
|
|
}
|
|
|
|
_renderFormula() {
|
|
const latex = this._el.latex.value;
|
|
const outHook = this.querySelector(".hook-out");
|
|
if (outHook) outHook.dataset.type = this.dataType;
|
|
this._el.rendered.innerHTML = "";
|
|
const vars = latex.trim() ? this._parseVariables(latex) : [];
|
|
this._rebuildVarHooks(vars);
|
|
if (!latex.trim()) return;
|
|
if (window.MathJax && MathJax.typesetPromise) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.textContent = "$$" + latex + "$$";
|
|
const typeset = () => {
|
|
MathJax.typesetPromise([wrapper]).then(() => {
|
|
this._el.rendered.innerHTML = wrapper.innerHTML;
|
|
}).catch(() => {
|
|
this._el.rendered.textContent = "\u26A0 error";
|
|
});
|
|
};
|
|
if (MathJax.startup && MathJax.startup.promise && MathJax.startup.promise.then) {
|
|
MathJax.startup.promise.then(typeset);
|
|
} else {
|
|
typeset();
|
|
}
|
|
} else {
|
|
this._el.rendered.textContent = latex;
|
|
}
|
|
}
|
|
|
|
generateCode(visited = new Set()) {
|
|
if (visited.has(this)) return "";
|
|
visited.add(this);
|
|
return this.generateOwnCode();
|
|
}
|
|
|
|
generateOwnCode() {
|
|
const name = this._el.name.value || "res";
|
|
const formula = this._el.latex.value || "0";
|
|
const type = this.dataType === "boolean" ? "int" : "double";
|
|
return `${type} ${name} = ${formula};`;
|
|
}
|
|
}
|