Files
avcf/wcblock.js
T
adrien 439cc6e7ba feat: loop support via goto labels on each block
- Every block now emits a C label (its id) before its code
- generateNextCode emits goto label; for back-edges (visited targets)
- _emitBranch delegates to generateCode (not generateOwnCode), so labels
  are emitted consistently in both main and branch traversals
- generateCallWithError threads a shared visited set across all three
  emitBranch calls, preventing duplicate label emission for shared blocks
- emitBranch skips roots already visited by previous roots in the same
  branch (prevents spurious gotos from shared-block traversal)
2026-06-23 01:16:14 +02:00

334 lines
12 KiB
JavaScript

// ── WCBlock — draggable/linkable block base class ──────────────────────────
//
// Base class for all draggable, linkable flowchart and measurement blocks.
// Provides grid-snapped drag-and-drop and a two-click link system between .hook buttons.
// Each block tracks incoming (wcForeFlow) and outgoing (wcNextFlow) SVG path references.
//
// Subclasses override generateCode() / generateOwnCode() to emit C code fragments.
//
// Load order dependencies:
// dom.js → svgNamespace, $id
// error.js → showError
// draw.js → linkageFirst, resetLinkage, startShadowLink, drawpath, deleteLink
//
// This file is loaded first among the block files.
// → blocks-algo.js, blocks-data.js, blocks-proc.js, wcprogram.js
/** Base class for all draggable, linkable flowchart and measurement blocks. */
class WCBlock extends HTMLElement {
constructor() {
super();
this.dataset.block = "";
this.addEventListener("mousedown", this.startDragging);
/** @type {SVGPathElement[]} Incoming flow paths (from other blocks to this one). */
this.wcForeFlow = [];
/** @type {SVGPathElement[]} Outgoing flow paths (from this block to others). */
this.wcNextFlow = [];
this._gridSize = null;
this._el = {};
this._dragBound = ev => this.dragAround(ev);
this._stopBound = () => this.stopDragging();
}
connectedCallback() {
if (this._hooksSetup) return;
this._hooksSetup = true;
for (const hook of this.querySelectorAll(".hook")) {
this._setupHook(hook);
}
this.addEventListener("mousedown", () => { this._dragMoved = false; });
this.addEventListener("click", ev => {
if (ev.target.closest(".hook") || ev.target.closest(".delete-btn") || ev.target.closest(".resize-handle")) return;
if (this._dragMoved) return;
ev.stopPropagation();
this._select();
});
const isAlgoBlock = !!this.closest("#draw-area");
if (!(this instanceof WCStart) && !(this instanceof WCEnd) && !this.dataset.readonly) {
const btn = document.createElement("button");
btn.className = "delete-btn";
btn.textContent = "\u00D7";
btn.title = "delete";
btn.addEventListener("click", ev => {
ev.stopPropagation();
this.delete();
});
this.appendChild(btn);
if (isAlgoBlock) {
const rh = document.createElement("div");
rh.className = "resize-handle";
rh.addEventListener("mousedown", ev => {
ev.stopPropagation();
ev.preventDefault();
const startX = ev.clientX, startY = ev.clientY;
const startW = this.offsetWidth, startH = this.offsetHeight;
const move = e => {
this.style.width = Math.max(60, startW + e.clientX - startX) + "px";
this.style.height = Math.max(30, startH + e.clientY - startY) + "px";
for (const p of this.wcNextFlow) drawpath(p);
for (const p of this.wcForeFlow) drawpath(p);
};
const up = () => {
document.removeEventListener("mousemove", move);
document.removeEventListener("mouseup", up);
};
document.addEventListener("mousemove", move);
document.addEventListener("mouseup", up);
});
this.appendChild(rh);
}
}
if (isAlgoBlock && !(this instanceof WCStart)) {
const eb = document.createElement("button");
eb.className = "edit-btn";
eb.title = "edit";
eb.innerHTML =
'<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor">' +
'<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 ' +
'7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 ' +
'0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>';
eb.addEventListener("click", ev => {
ev.stopPropagation();
showBlockEditor(this);
});
this.appendChild(eb);
}
if (isAlgoBlock) {
const warn = document.createElement("span");
warn.className = "block-warn";
warn.textContent = "\u26A0";
this.appendChild(warn);
setTimeout(() => this._checkLogicInput(), 0);
}
/* Replace inputs with value labels so blocks show compact info.
The edit modal provides full editing. */
if (isAlgoBlock) {
for (const field of this.querySelectorAll("input, select, textarea")) {
const label = document.createElement("span");
label.className = "field-display";
const update = () => {
if (field.tagName === "SELECT") {
label.textContent = field.options[field.selectedIndex]?.text || "";
} else {
label.textContent = field.value || "";
}
};
update();
field.addEventListener("change", update);
field.addEventListener("input", update);
field.parentNode.insertBefore(label, field);
field.style.display = "none";
}
}
}
/** Checks if this block has an incoming logic flow; toggles the `.disconnected` class. */
_checkLogicInput() {
const hook = this.querySelector('.hook[data-direction="in"][data-link-type="logic"]');
if (!hook) return;
const has = this.wcForeFlow.some(p => p.toHook === hook);
if (!has) this.classList.add("disconnected");
else this.classList.remove("disconnected");
}
/**
* Finds a required child element or throws an informative error.
* @param {string} selector CSS selector
* @returns {HTMLElement}
*/
_require(selector) {
const el = this.querySelector(selector);
if (!el) throw new Error(`${this.tagName}: missing required element "${selector}"`);
return el;
}
/** Marks this block as selected (adds `.selected` class, removes it from all others). */
_select() {
for (const el of document.querySelectorAll(".selected")) {
el.classList.remove("selected");
}
this.classList.add("selected");
}
/** Removes this block and all connected flow paths, cleaning up arrays. */
delete() {
resetLinkage();
for (const path of [...this.wcForeFlow]) deleteLink(path);
for (const path of [...this.wcNextFlow]) deleteLink(path);
this.wcForeFlow = [];
this.wcNextFlow = [];
this.remove();
refreshAll();
}
/**
* Attaches two-click link behaviour to a single `.hook` button element.
* @param {HTMLElement} hook
*/
_setupHook(hook) {
hook.addEventListener("click", ev => {
ev.stopPropagation();
const linkType = hook.dataset.linkType;
if (!linkageFirst) {
if (hook.dataset.direction !== "out") return;
linkageFirst = { block: this, hook };
startShadowLink(hook);
} else if (this === linkageFirst.block) {
showError("Cannot link a block to itself.");
resetLinkage();
} else {
if (hook.dataset.direction !== "in") {
showError("Must link to an input hook.");
resetLinkage();
return;
}
const firstType = linkageFirst.hook.dataset.linkType;
if (firstType !== linkType) {
showError("Cannot link " + (firstType || "logic") + " to " + (linkType || "logic") + " hook.");
resetLinkage();
return;
}
if (linkType === "data") {
if (linkageFirst.hook.dataset.type !== hook.dataset.type) {
showError("Data type mismatch: " + (linkageFirst.hook.dataset.type || "?") + " \u2260 " + (hook.dataset.type || "?"));
resetLinkage();
return;
}
}
const path = document.createElementNS(svgNamespace, "path");
path.toElement = this;
path.fromElement = linkageFirst.block;
path.fromHook = linkageFirst.hook;
path.toHook = hook;
hook.dataset.connected = "";
path.dataset.linkType = linkType || "logic";
path.classList.add(linkType === "data" ? "flow-data" : "flow");
if (linkageFirst.hook.classList.contains("proccall-error-hook")) {
path.classList.add("flow-error-source");
path.classList.add("flow-error");
} else if (linkType !== "data" && linkageFirst.block.classList.contains("error-path")) {
path.classList.add("flow-error");
}
this.wcForeFlow.push(path);
linkageFirst.block.wcNextFlow.push(path);
$id("connections").appendChild(path);
drawpath(path);
if (linkType === "data") {
_dataSourceCache.set(hook, linkageFirst.block);
}
refreshBlockWarnings();
refreshErrorPaths();
resetLinkage();
}
});
}
/** @returns {string|null} The data type this block exposes (overridden by subclasses). */
get dataType() { return null; }
/** @returns {Object} Serializable data for persistence (overridden by subclasses). */
saveData() { return {}; }
/**
* Restores block state from saved data (overridden by subclasses).
* @param {Object} data
*/
restoreData(data) {}
/** @returns {string} C code fragment for this single block (overridden by subclasses). */
generateOwnCode() { return ""; }
/**
* Generates C code for this block and its successors, respecting the visited set
* to avoid infinite loops. Each block emits a C label (its id) so that loop
* back-edges can target it with `goto label;`.
* @param {Set} [visited]
* @returns {string}
*/
generateCode(visited = new Set()) {
if (visited.has(this)) return "\n goto " + this.id + ";\n";
visited.add(this);
return "\n" + this.id + ":\n" + this.generateOwnCode() + this.generateNextCode(visited);
}
/**
* Walks wcNextFlow paths and accumulates code from downstream blocks.
* When a target is already visited (loop back-edge), emits `goto label;`
* instead of silently dropping the path.
* @param {Set} visited
* @returns {string}
*/
generateNextCode(visited) {
let code = "";
for (const path of this.wcNextFlow) {
const target = path.toElement;
if (visited.has(target)) {
code += "\n goto " + target.id + ";\n";
} else {
code += target.generateCode(visited);
}
}
return code;
}
/** @returns {number} The CSS `--grid-size` value, cached after first read (default 25). */
_getGridSize() {
if (this._gridSize === null) {
this._gridSize = parseInt(
window.getComputedStyle(this).getPropertyValue("--grid-size")
);
}
if (isNaN(this._gridSize)) this._gridSize = 25;
return this._gridSize;
}
/**
* Begins a drag operation; records start position and binds move/up listeners.
* @param {MouseEvent} event
*/
startDragging(event) {
if (event.target.closest(".resize-handle")) return;
if (!this.closest("#draw-area")) return;
this._dragMoved = false;
this._getGridSize();
this.dragStartX = event.clientX;
this.dragStartY = event.clientY;
document.addEventListener("mousemove", this._dragBound);
document.addEventListener("mouseup", this._stopBound);
}
/**
* Handles mouse move during drag; grid-snaps position and enforces section border constraint.
* @param {MouseEvent} event
*/
dragAround(event) {
this._dragMoved = true;
const snap = v => Math.round(v / this._gridSize) * this._gridSize;
const prevTop = this.style.top, prevLeft = this.style.left;
this.style.top = snap(this.offsetTop + event.clientY - this.dragStartY) + "px";
this.style.left = snap(this.offsetLeft + event.clientX - this.dragStartX) + "px";
if (typeof blockOnSectionBorder === "function" && blockOnSectionBorder(this)) {
this.style.top = prevTop;
this.style.left = prevLeft;
} else {
this.dragStartX = snap(event.clientX);
this.dragStartY = snap(event.clientY);
}
for (const p of this.wcNextFlow) drawpath(p);
for (const p of this.wcForeFlow) drawpath(p);
}
/** Ends the drag operation and removes move/up listeners. */
stopDragging() {
document.removeEventListener("mousemove", this._dragBound);
document.removeEventListener("mouseup", this._stopBound);
}
}