- 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)
334 lines
12 KiB
JavaScript
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);
|
|
}
|
|
}
|