- Remove global-toolbar (footer with code display) entirely - Move Generate, Save, Load, New, Tests buttons to algorithm pane toolbar - Replace prog.generateCode() with prog.generateAndDownload() — creates a Blob from the IR and triggers a program.ll download via a temp <a> - Remove copyCode function from helpers.js (no longer needed) - Remove global-toolbar and .code-output CSS rules - Add .action-btn styling for the text buttons in pane-toolbar
530 lines
19 KiB
JavaScript
530 lines
19 KiB
JavaScript
// ── Utility functions & dropdown refresh ─────────────────────────────────────
|
|
//
|
|
// Pure helper functions shared across blocks, persistence, and app modules.
|
|
// Depends on: dom.js ($, $id)
|
|
//
|
|
// Exports:
|
|
// splitValueAndUnit() → parse "5 km" → {numVal, unitSuffix}
|
|
// rebuildSelect() → replace <select> options preserving selection
|
|
// refreshQuantityOptions() → sync quantity-name dropdowns + "boolean"
|
|
// refreshUnitOptions() → sync unit-suffix dropdowns from unit blocks
|
|
// getDefaultUnitSuffix() → lookup default unit for a quantity name
|
|
// refreshDefaultUnitOptions() → sync default-unit dropdowns on quantity blocks
|
|
// refreshVarRefOptions() → sync variable-select dropdowns
|
|
// switchLibTab() → activate library sub-tab
|
|
// findDataSource() → find block connected to a data hook
|
|
// escapeString() → escape special chars for C string literal
|
|
// refreshBlockWarnings() → check every algorithm block for missing input
|
|
// refreshErrorPaths() → mark blocks only reachable via error-flow paths
|
|
// refreshAll() → run all refresh passes in order
|
|
// copyCode() → copy generated C code to clipboard
|
|
|
|
|
|
/**
|
|
* Parses a string like "5 km" into a numeric value and an optional unit suffix.
|
|
* Falls back to parsing the entire string as a bare number when no suffix is present.
|
|
* @param {string} str - e.g. "5 km", "42", ""
|
|
* @param {number} defaultNum - fallback when parseFloat yields NaN
|
|
* @returns {{ numVal: number, unitSuffix: string|null }}
|
|
*/
|
|
function splitValueAndUnit(str, defaultNum) {
|
|
const trimmed = (str || "").trim();
|
|
const parts = trimmed.split(/\s+/);
|
|
let numVal, unitSuffix;
|
|
if (parts.length < 2 || isNaN(parseFloat(parts[0]))) {
|
|
numVal = parseFloat(trimmed);
|
|
unitSuffix = null;
|
|
} else {
|
|
numVal = parseFloat(parts[0]);
|
|
unitSuffix = parts.slice(1).join(" ");
|
|
}
|
|
return { numVal: isNaN(numVal) ? defaultNum : numVal, unitSuffix };
|
|
}
|
|
|
|
/**
|
|
* Replaces all options in a <select>, preserving the current selection if it still exists.
|
|
* @param {HTMLSelectElement} sel - the select element to rebuild
|
|
* @param {string} placeholder - text for the first (empty-value) option
|
|
* @param {string[]} values - option value/label pairs to add
|
|
* @param {string} current - currently selected value to attempt to preserve
|
|
*/
|
|
function rebuildSelect(sel, placeholder, values, current) {
|
|
sel.innerHTML = `<option value="">${placeholder}</option>`;
|
|
const frag = document.createDocumentFragment();
|
|
for (const v of values) {
|
|
const opt = document.createElement("option");
|
|
opt.value = v;
|
|
opt.textContent = v;
|
|
frag.appendChild(opt);
|
|
}
|
|
sel.appendChild(frag);
|
|
if (current && [...sel.options].some(o => o.value === current)) {
|
|
sel.value = current;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes every quantity-name dropdown (.var-type, .unit-type) by scanning
|
|
* all wc-quantity blocks in the data area.
|
|
*/
|
|
function refreshQuantityOptions() {
|
|
const dataArea = $id("data-area");
|
|
if (!dataArea) return;
|
|
const qNames = Array.from(
|
|
dataArea.querySelectorAll("wc-quantity")
|
|
).map(q => {
|
|
const el = q.querySelector(".quantity-name");
|
|
return el ? (el.value || "unnamed") : "unnamed";
|
|
});
|
|
|
|
document.querySelectorAll(
|
|
"#variable-area .var-type, #data-area .unit-type"
|
|
).forEach(sel => {
|
|
const placeholder = sel.classList.contains("var-type")
|
|
? "(no type)" : "(select quantity)";
|
|
rebuildSelect(sel, placeholder, qNames, sel.value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refreshes the unit-suffix dropdown on every datum block to show only units
|
|
* that match the datum's currently selected quantity.
|
|
*/
|
|
function refreshUnitOptions() {
|
|
const varArea = $id("variable-area");
|
|
const dataArea = $id("data-area");
|
|
if (!varArea || !dataArea) return;
|
|
varArea.querySelectorAll("wc-scalar").forEach(d => {
|
|
const typeEl = d.querySelector(".var-type");
|
|
const sel = d.querySelector(".var-unit");
|
|
if (!typeEl || !sel) return;
|
|
const quantityName = typeEl.value;
|
|
const suffixes = Array.from(
|
|
dataArea.querySelectorAll("wc-unit")
|
|
)
|
|
.filter(
|
|
u => {
|
|
const t = u.querySelector(".unit-type");
|
|
const s = u.querySelector(".unit-suffix");
|
|
return t && s && t.value === quantityName && s.value;
|
|
}
|
|
)
|
|
.map(u => {
|
|
const s = u.querySelector(".unit-suffix");
|
|
return s ? s.value : "";
|
|
});
|
|
rebuildSelect(sel, "(unit)", suffixes, sel.value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Looks up the default unit suffix for a given quantity name.
|
|
* @param {string} quantityName
|
|
* @returns {string} default unit suffix, or "" if not found
|
|
*/
|
|
function getDefaultUnitSuffix(quantityName) {
|
|
const dataArea = $id("data-area");
|
|
if (!dataArea) return "";
|
|
for (const q of dataArea.querySelectorAll("wc-quantity")) {
|
|
const nameEl = q.querySelector(".quantity-name");
|
|
if (nameEl && (nameEl.value || "unnamed") === quantityName) {
|
|
const unitEl = q.querySelector(".quantity-default-unit");
|
|
return unitEl ? (unitEl.value || "") : "";
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Refreshes the default-unit dropdown on every quantity block with suffixes
|
|
* of matching unit blocks.
|
|
*/
|
|
function refreshDefaultUnitOptions() {
|
|
const dataArea = $id("data-area");
|
|
if (!dataArea) return;
|
|
dataArea.querySelectorAll("wc-quantity").forEach(q => {
|
|
const nameEl = q.querySelector(".quantity-name");
|
|
const sel = q.querySelector(".quantity-default-unit");
|
|
if (!nameEl || !sel) return;
|
|
const quantityName = nameEl.value || "unnamed";
|
|
const suffixes = Array.from(
|
|
dataArea.querySelectorAll("wc-unit")
|
|
)
|
|
.filter(
|
|
u => {
|
|
const t = u.querySelector(".unit-type");
|
|
const s = u.querySelector(".unit-suffix");
|
|
return t && s && t.value === quantityName && s.value;
|
|
}
|
|
)
|
|
.map(u => {
|
|
const s = u.querySelector(".unit-suffix");
|
|
return s ? s.value : "";
|
|
});
|
|
rebuildSelect(sel, "(none)", suffixes, sel.value);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Refreshes the variable-select dropdown on every wc-varref block in the
|
|
* algorithm pane. Scans all boolean, scalar, string, and array blocks.
|
|
*/
|
|
function refreshVarRefOptions() {
|
|
const selects = document.querySelectorAll(".varref-select, .assign-target");
|
|
if (!selects.length) return;
|
|
const varArea = $id("variable-area");
|
|
const vars = [];
|
|
if (varArea) {
|
|
for (const b of varArea.querySelectorAll("wc-boolean, wc-scalar, wc-string, wc-array, wc-vector")) {
|
|
const nameEl = b.querySelector(".var-name");
|
|
const name = nameEl ? (nameEl.value || "unnamed") : "unnamed";
|
|
const tag = b.tagName.toLowerCase().replace("wc-", "");
|
|
vars.push({ id: b.id, label: tag + ": " + name });
|
|
}
|
|
}
|
|
for (const sel of selects) {
|
|
const current = sel.dataset.restoredValue || sel.value;
|
|
delete sel.dataset.restoredValue;
|
|
sel.innerHTML = '<option value="">(select variable)</option>';
|
|
for (const v of vars) {
|
|
const opt = document.createElement("option");
|
|
opt.value = v.id;
|
|
opt.textContent = v.label;
|
|
sel.appendChild(opt);
|
|
}
|
|
if (current && [...sel.options].some(o => o.value === current)) {
|
|
sel.value = current;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Activates the clicked library sub-tab and shows its corresponding pane.
|
|
* @param {string} name - library name (matches data-lib-name attribute)
|
|
*/
|
|
function switchLibTab(name) {
|
|
for (const tab of document.querySelectorAll(".lib-tab")) {
|
|
tab.classList.toggle("active", tab.dataset.libName === name);
|
|
}
|
|
for (const pane of document.querySelectorAll(".lib-pane")) {
|
|
pane.classList.toggle("active", pane.dataset.libName === name);
|
|
}
|
|
}
|
|
|
|
/** @type {WeakMap<HTMLElement,HTMLElement>} Cache mapping data hooks to their source blocks. */
|
|
const _dataSourceCache = new WeakMap();
|
|
|
|
/**
|
|
* Finds the block connected to a given data hook via any .flow-data path.
|
|
* Results are cached in _dataSourceCache.
|
|
* @param {HTMLElement} hook - the data hook button element
|
|
* @returns {HTMLElement|null} the source block, or null if nothing is connected
|
|
*/
|
|
function findDataSource(hook) {
|
|
if (_dataSourceCache.has(hook)) return _dataSourceCache.get(hook);
|
|
for (const path of document.querySelectorAll("path.flow-data")) {
|
|
if (path.toHook === hook) {
|
|
_dataSourceCache.set(hook, path.fromElement);
|
|
return path.fromElement;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Copies the generated C code text to the clipboard. */
|
|
/**
|
|
* Escapes special characters for embedding in a C string literal.
|
|
* Handles backslash, double-quote, newline, carriage return, and tab.
|
|
* @param {string} str - raw string
|
|
* @returns {string} escaped string safe for C "..." literals
|
|
*/
|
|
function escapeString(str) {
|
|
return str
|
|
.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
|
.replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t")
|
|
.replace(/\f/g, "\\f").replace(/\v/g, "\\v").replace(/\0/g, "\\0");
|
|
}
|
|
|
|
/** @type {Set<string>} C reserved keywords — toCName appends "_" to avoid clashes. */
|
|
const C_KEYWORDS = new Set([
|
|
"auto","break","case","char","const","continue","default","do","double",
|
|
"else","enum","extern","float","for","goto","if","int","long","register",
|
|
"return","short","signed","sizeof","static","struct","switch","typedef",
|
|
"union","unsigned","void","volatile","while"
|
|
]);
|
|
|
|
/**
|
|
* Converts a natural-language name into a valid C identifier.
|
|
* Lowercases, replaces non-alphanumeric chars with underscores,
|
|
* collapses runs of underscores, strips leading/trailing underscores,
|
|
* and prepends an underscore if the result starts with a digit.
|
|
* @param {string} str - natural name like "My Variable 2"
|
|
* @returns {string} valid C identifier like "my_variable_2"
|
|
*/
|
|
function toCName(str) {
|
|
let s = (str || "").trim().toLowerCase();
|
|
s = s.replace(/[^a-z0-9_]/g, "_");
|
|
s = s.replace(/_+/g, "_");
|
|
s = s.replace(/^_|_$/g, "");
|
|
if (/^\d/.test(s)) s = "_" + s;
|
|
if (!s || C_KEYWORDS.has(s)) s = (s || "var") + "_";
|
|
return s;
|
|
}
|
|
|
|
/**
|
|
* Derives a human-readable natural name from a C function/tech name.
|
|
* Converts camelCase and snake_case to words, strips common prefixes.
|
|
* @param {string} techName - e.g. "SDL_CreateWindow", "glfwInit", "puts"
|
|
* @returns {string} e.g. "Create Window", "Init GLFW", "Puts"
|
|
*/
|
|
function deriveNaturalName(techName) {
|
|
if (!techName) return "";
|
|
let s = techName;
|
|
s = s.replace(/^(SDL_|glfw|gl_?)/i, '');
|
|
const known = {
|
|
puts: "Print String",
|
|
printf: "Print Formatted",
|
|
getchar: "Get Char",
|
|
putchar: "Put Char",
|
|
perror: "Print Error",
|
|
malloc: "Allocate Memory",
|
|
atoi: "Parse Int",
|
|
atof: "Parse Float",
|
|
strlen: "String Length",
|
|
strcmp: "Compare Strings",
|
|
fabs: "Abs Float",
|
|
fmod: "Float Modulo",
|
|
};
|
|
if (known[s]) return known[s];
|
|
s = s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
|
s = s.replace(/_/g, ' ');
|
|
s = s.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
|
|
s = s.replace(/\s+/g, ' ').trim();
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
/**
|
|
* Builds a C comment prefix from a variable's natural name and an optional comment.
|
|
* @param {string} naturalName - the human-readable variable name
|
|
* @param {string} comment - the optional comment text
|
|
* @returns {string} e.g. "// My Flag: indicates status\n" or "" when no comment
|
|
*/
|
|
function commentPrefix(naturalName, comment) {
|
|
naturalName = (naturalName || "").trim();
|
|
comment = (comment || "").trim();
|
|
return comment && naturalName ? `// ${naturalName}: ${comment}\n` : comment ? `// ${comment}\n` : "";
|
|
}
|
|
|
|
/**
|
|
* Resolves the ultimate C variable name from a block by checking for
|
|
* .var-name, .expr-name, and following .varref-select chains.
|
|
* Returns the empty string when nothing is found.
|
|
* @param {HTMLElement} block - any block that may hold a variable reference
|
|
* @param {Set} [visited] - cycle detection set (internal use)
|
|
* @returns {string} the C name, or ""
|
|
*/
|
|
function resolveSourceName(block, visited) {
|
|
visited = visited || new Set();
|
|
if (visited.has(block)) return "";
|
|
visited.add(block);
|
|
|
|
const ni = block.querySelector(".var-name");
|
|
if (ni) return toCName(ni.value) || "x";
|
|
const ei = block.querySelector(".expr-name");
|
|
if (ei) return ei.value || "res";
|
|
const vr = block.querySelector(".varref-select");
|
|
if (vr) {
|
|
const target = vr.value ? $id(vr.value) : null;
|
|
if (target) return resolveSourceName(target, visited);
|
|
return vr.value || "var";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Opens a modal dialog that shows the block's editable fields in a compact,
|
|
* full-width layout for easier editing.
|
|
* @param {HTMLElement} block
|
|
*/
|
|
function showBlockEditor(block) {
|
|
let overlay = $id("block-editor-overlay");
|
|
if (!overlay) {
|
|
overlay = document.createElement("div");
|
|
overlay.id = "block-editor-overlay";
|
|
overlay.className = "block-editor-overlay";
|
|
overlay.addEventListener("click", ev => { if (ev.target === overlay) overlay.classList.remove("active"); });
|
|
document.body.appendChild(overlay);
|
|
|
|
const editorDiv = document.createElement("div");
|
|
editorDiv.className = "block-editor-modal";
|
|
editorDiv.innerHTML =
|
|
'<div class="block-editor-header">' +
|
|
' <span class="block-editor-title">Edit Block</span>' +
|
|
' <button class="block-editor-close">×</button>' +
|
|
'</div>' +
|
|
'<div class="block-editor-body"></div>' +
|
|
'<div class="block-editor-footer">' +
|
|
' <button class="block-editor-cancel">Cancel</button>' +
|
|
' <button class="block-editor-apply">Apply</button>' +
|
|
'</div>';
|
|
overlay.appendChild(editorDiv);
|
|
|
|
editorDiv.querySelector(".block-editor-close").addEventListener("click", () => overlay.classList.remove("active"));
|
|
editorDiv.querySelector(".block-editor-cancel").addEventListener("click", () => overlay.classList.remove("active"));
|
|
}
|
|
|
|
const body = overlay.querySelector(".block-editor-body");
|
|
body.innerHTML = "";
|
|
|
|
/* Colour picker row; error blocks default to red, others to blue */
|
|
const defaultColor = block.classList.contains("error-path") || block.dataset.exitError ? "#ef5350" : "#4a90d9";
|
|
const colorRow = document.createElement("div");
|
|
colorRow.className = "block-editor-color-row";
|
|
colorRow.innerHTML =
|
|
'<label class="block-editor-color-label">Accent colour</label>' +
|
|
'<input type="color" class="block-editor-color-picker" value="' + (block.dataset.blockColor || defaultColor) + '">' +
|
|
'<button class="block-editor-color-reset" title="reset to default">Reset</button>';
|
|
body.appendChild(colorRow);
|
|
|
|
const colorPicker = colorRow.querySelector(".block-editor-color-picker");
|
|
const origColor = block.dataset.blockColor || "";
|
|
|
|
colorRow.querySelector(".block-editor-color-reset").addEventListener("click", () => {
|
|
colorPicker.value = defaultColor;
|
|
});
|
|
|
|
const clone = block.cloneNode(true);
|
|
clone.style.position = "static";
|
|
clone.style.width = "";
|
|
clone.style.height = "";
|
|
// Remove interactive buttons from clone (edit, delete, resize)
|
|
for (const btn of clone.querySelectorAll(".edit-btn, .delete-btn, .resize-handle, .hook")) btn.remove();
|
|
// Restore hidden inputs and remove their labels so the modal shows editable fields
|
|
for (const label of clone.querySelectorAll(".field-display")) {
|
|
const input = label.nextElementSibling;
|
|
if (input && (input.tagName === "INPUT" || input.tagName === "SELECT" || input.tagName === "TEXTAREA")) {
|
|
input.style.display = "";
|
|
}
|
|
label.remove();
|
|
}
|
|
body.appendChild(clone);
|
|
|
|
overlay.querySelector(".block-editor-title").textContent = "Edit " + (block.tagName || "Block");
|
|
overlay.classList.add("active");
|
|
|
|
overlay.querySelector(".block-editor-apply").onclick = () => {
|
|
// Sync inputs/selects/textareas from clone back to original
|
|
const srcFields = block.querySelectorAll("input, select, textarea");
|
|
const cloneFields = clone.querySelectorAll("input, select, textarea");
|
|
for (let i = 0; i < srcFields.length && i < cloneFields.length; i++) {
|
|
if (srcFields[i].type !== "hidden") srcFields[i].value = cloneFields[i].value;
|
|
}
|
|
// Trigger change and input events so block internals sync
|
|
for (const el of srcFields) {
|
|
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
// For END blocks: parse and select the errno from the synced value
|
|
if (block.tagName === "WC-END" && typeof block._selectByCode === "function") {
|
|
const val = block._el?.input?.value || "";
|
|
const code = parseInt(val);
|
|
if (!isNaN(code)) block._selectByCode(code);
|
|
}
|
|
|
|
/* Apply colour */
|
|
const newColor = colorPicker.value;
|
|
if (newColor !== defaultColor) {
|
|
block.dataset.blockColor = newColor;
|
|
block.style.borderColor = newColor;
|
|
block.style.borderWidth = "2px";
|
|
} else {
|
|
delete block.dataset.blockColor;
|
|
block.style.borderColor = "";
|
|
block.style.borderWidth = "";
|
|
}
|
|
|
|
overlay.classList.remove("active");
|
|
};
|
|
}
|
|
|
|
/** Check every algorithm block and flag those without an incoming logic connection. */
|
|
function refreshBlockWarnings() {
|
|
for (const block of document.querySelectorAll("#main-program > [data-block]")) {
|
|
if (typeof block._checkLogicInput === "function") block._checkLogicInput();
|
|
}
|
|
|
|
/* Warn when a wc-proccall has no connection from its error (!) output hook. */
|
|
for (const block of document.querySelectorAll("wc-proccall")) {
|
|
const errHook = block.querySelector(".proccall-error-hook");
|
|
if (!errHook) continue;
|
|
const hasConnection = block.wcNextFlow.some(p => p.fromHook === errHook);
|
|
block.classList.toggle("warn-error-unlinked", !hasConnection);
|
|
}
|
|
|
|
/* Warn when an error-path END block returns 0 (success in an error branch). */
|
|
for (const block of document.querySelectorAll("wc-end.error-path")) {
|
|
block.classList.toggle("warn-zero-in-error", block._selectedCode === 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Marks algorithm blocks that are reachable ONLY via error-flow paths.
|
|
* Adds/removes the `.error-path` class so they render with a red highlight.
|
|
*/
|
|
function refreshErrorPaths() {
|
|
for (const el of document.querySelectorAll("[data-block].error-path")) {
|
|
el.classList.remove("error-path");
|
|
}
|
|
for (const p of document.querySelectorAll("path.flow.flow-error")) {
|
|
p.classList.remove("flow-error");
|
|
}
|
|
|
|
const errorHooks = document.querySelectorAll("path.flow.flow-error-source");
|
|
if (errorHooks.length === 0) return;
|
|
|
|
const errorRoots = new Set();
|
|
for (const path of errorHooks) {
|
|
if (path.toElement) errorRoots.add(path.toElement);
|
|
path.classList.add("flow-error");
|
|
}
|
|
|
|
const normalReachable = new Set();
|
|
const start = $id("main-start");
|
|
if (start) {
|
|
const q = [start];
|
|
while (q.length) {
|
|
const el = q.pop();
|
|
if (normalReachable.has(el)) continue;
|
|
normalReachable.add(el);
|
|
for (const p of el.wcNextFlow) {
|
|
if (p.classList.contains("flow-error-source")) continue;
|
|
if (p.toElement) q.push(p.toElement);
|
|
}
|
|
}
|
|
}
|
|
|
|
const eq = [...errorRoots];
|
|
while (eq.length) {
|
|
const el = eq.pop();
|
|
if (normalReachable.has(el)) continue;
|
|
if (el.classList.contains("error-path")) continue;
|
|
el.classList.add("error-path");
|
|
for (const p of el.wcNextFlow) {
|
|
if (p.toElement) {
|
|
p.classList.add("flow-error");
|
|
eq.push(p.toElement);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Runs all refresh passes in dependency order. */
|
|
function refreshAll() {
|
|
refreshQuantityOptions();
|
|
refreshUnitOptions();
|
|
refreshDefaultUnitOptions();
|
|
refreshVarRefOptions();
|
|
refreshBlockWarnings();
|
|
refreshErrorPaths();
|
|
}
|