- 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
265 lines
8.7 KiB
JavaScript
265 lines
8.7 KiB
JavaScript
// ── SVG connection drawing, direction routing, shadow link & deletion ──────────
|
|
//
|
|
// Manages the two-click link system between block hooks, draws polyline SVG
|
|
// paths constrained to {0°, 45°, 90°} angles, renders a live shadow path
|
|
// from the selected output hook to the cursor while linking, and handles
|
|
// link deletion with full block array cleanup.
|
|
//
|
|
// Depends on: dom.js (svgNamespace, $, $id)
|
|
//
|
|
|
|
/** @type {number} Pixels the path extends from a hook in its exit direction. */
|
|
const EXT = 25;
|
|
|
|
/** @type {{block: HTMLElement, hook: HTMLElement}|null} First hook clicked in a two-click link operation. */
|
|
let linkageFirst = null;
|
|
|
|
/** @type {SVGPathElement|null} The shadow path element shown while linking, or null. */
|
|
let shadowPath = null;
|
|
|
|
|
|
/**
|
|
* Routes a 45°-constrained polyline between two points.
|
|
* Returns the SVG path data (everything after the initial "M") as a string
|
|
* suitable for appending to an existing moveto.
|
|
* @param {number} x1 - start x
|
|
* @param {number} y1 - start y
|
|
* @param {number} x2 - end x
|
|
* @param {number} y2 - end y
|
|
* @returns {string} SVG path data segment (e.g. "L 100 50 L 150 50")
|
|
*/
|
|
function routeBetween(x1, y1, x2, y2) {
|
|
const dx = x2 - x1, dy = y2 - y1;
|
|
const adx = Math.abs(dx), ady = Math.abs(dy);
|
|
|
|
if (adx === 0 && ady === 0) return `L ${x2} ${y2}`;
|
|
|
|
const sx = dx >= 0 ? 1 : -1;
|
|
const sy = dy >= 0 ? 1 : -1;
|
|
const R45 = Math.PI / 4, R90 = Math.PI / 2, TOL = 5 * Math.PI / 180;
|
|
const normAngle = (() => {
|
|
const raw = Math.abs(Math.atan2(dy, dx));
|
|
return raw > R90 ? Math.PI - raw : raw;
|
|
})();
|
|
|
|
if (
|
|
Math.abs(normAngle) < TOL ||
|
|
Math.abs(normAngle - R45) < TOL ||
|
|
Math.abs(normAngle - R90) < TOL
|
|
) {
|
|
return `L ${x2} ${y2}`;
|
|
}
|
|
|
|
if (adx >= ady) {
|
|
const x1m = x1 + sx * ((adx - ady) / 2);
|
|
const x2m = x1m + sx * ady;
|
|
return `L ${x1m} ${y1} L ${x2m} ${y2} L ${x2} ${y2}`;
|
|
} else {
|
|
const y1m = y1 + sy * ((ady - adx) / 2);
|
|
const y2m = y1m + sy * adx;
|
|
return `L ${x1} ${y1m} L ${x2} ${y2m} L ${x2} ${y2}`;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Resolves a hook DOM element, preferring the explicitly stored reference
|
|
* and falling back to the block's first .hook child.
|
|
* @param {SVGPathElement} path
|
|
* @param {string} which - "from" or "to"
|
|
* @returns {HTMLElement} the hook button
|
|
*/
|
|
function resolveHook(path, which) {
|
|
const key = which === "from" ? "fromHook" : "toHook";
|
|
const elKey = which === "from" ? "fromElement" : "toElement";
|
|
if (path[key]) return path[key];
|
|
const el = path[elKey];
|
|
return el ? el.querySelector(".hook") : null;
|
|
}
|
|
|
|
|
|
/**
|
|
* Computes the extended control point for a hook: the hook centre pushed
|
|
* `EXT` pixels in the hook's declared exit direction.
|
|
* @param {HTMLElement} hook - the hook button element
|
|
* @param {DOMRect} svgRect - bounding rect of the SVG viewport
|
|
* @returns {{ x: number, y: number }}
|
|
*/
|
|
function getHookExit(hook, svgRect) {
|
|
if (!hook) return { x: 0, y: 0 };
|
|
const r = hook.getBoundingClientRect();
|
|
const x = (r.left + r.right) / 2 - svgRect.left;
|
|
const y = (r.top + r.bottom) / 2 - svgRect.top;
|
|
const dir = hook.dataset.exit;
|
|
if (dir === "right") return { x: x + EXT, y };
|
|
if (dir === "left") return { x: x - EXT, y };
|
|
if (dir === "down") return { x, y: y + EXT };
|
|
if (dir === "up") return { x, y: y - EXT };
|
|
return { x, y };
|
|
}
|
|
|
|
|
|
/**
|
|
* Draws (or redraws) an SVG connection path between two linked block hooks.
|
|
* Each segment starts by extending EXT pixels in the source hook's exit
|
|
* direction and ends by approaching from the target hook's exit direction.
|
|
* The middle portion uses 45°-constrained routing.
|
|
* @param {SVGPathElement} path - path element with fromHook / toHook references
|
|
*/
|
|
function drawpath(path) {
|
|
if (!path || !path.isConnected) return;
|
|
const svg = path.closest("svg");
|
|
if (!svg) return;
|
|
const svgRect = svg.getBoundingClientRect();
|
|
|
|
const fromHook = resolveHook(path, "from");
|
|
const toHook = resolveHook(path, "to");
|
|
if (!fromHook || !toHook || !fromHook.isConnected || !toHook.isConnected) return;
|
|
const rFrom = fromHook.getBoundingClientRect();
|
|
const rTo = toHook.getBoundingClientRect();
|
|
|
|
const fx = (rFrom.left + rFrom.right) / 2 - svgRect.left;
|
|
const fy = (rFrom.top + rFrom.bottom) / 2 - svgRect.top;
|
|
const tx = (rTo.left + rTo.right) / 2 - svgRect.left;
|
|
const ty = (rTo.top + rTo.bottom) / 2 - svgRect.top;
|
|
|
|
const srcExit = getHookExit(fromHook, svgRect);
|
|
const tgtExit = getHookExit(toHook, svgRect);
|
|
|
|
path.setAttribute("d",
|
|
`M ${fx} ${fy} L ${srcExit.x} ${srcExit.y}` +
|
|
routeBetween(srcExit.x, srcExit.y, tgtExit.x, tgtExit.y) +
|
|
` L ${tx} ${ty}`
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Programmatically connects two hooks with a logic flow link.
|
|
* @param {HTMLElement} fromBlock - source block element
|
|
* @param {string} fromHookSel - CSS selector for the source hook (e.g. ".hook", "#hook-fore")
|
|
* @param {HTMLElement} toBlock - target block element
|
|
* @param {string} toHookSel - CSS selector for the target hook
|
|
*/
|
|
function _linkFlow(fromBlock, fromHookSel, toBlock, toHookSel) {
|
|
const fromHook = fromBlock.querySelector(fromHookSel);
|
|
const toHook = toBlock.querySelector(toHookSel);
|
|
if (!fromHook || !toHook) return;
|
|
const path = document.createElementNS(svgNamespace, "path");
|
|
path.toElement = toBlock;
|
|
path.fromElement = fromBlock;
|
|
path.fromHook = fromHook;
|
|
path.toHook = toHook;
|
|
toHook.dataset.connected = "";
|
|
path.dataset.linkType = "logic";
|
|
path.classList.add("flow");
|
|
if (fromHook.classList.contains("proccall-error-hook")) {
|
|
path.classList.add("flow-error-source");
|
|
path.classList.add("flow-error");
|
|
} else if (fromBlock.classList.contains("error-path")) {
|
|
path.classList.add("flow-error");
|
|
}
|
|
toBlock.wcForeFlow.push(path);
|
|
fromBlock.wcNextFlow.push(path);
|
|
document.getElementById("connections").appendChild(path);
|
|
drawpath(path);
|
|
refreshErrorPaths();
|
|
}
|
|
|
|
|
|
// ── Shadow (preview) link ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Begins showing a dashed shadow path from the given hook to the cursor.
|
|
* @param {HTMLElement} hook - the source hook button
|
|
*/
|
|
function startShadowLink(hook) {
|
|
if (shadowPath) endShadowLink();
|
|
const svg = $id("connections");
|
|
shadowPath = document.createElementNS(svgNamespace, "path");
|
|
shadowPath.classList.add(hook.dataset.linkType === "data" ? "flow-shadow-data" : "flow-shadow");
|
|
shadowPath._hook = hook;
|
|
svg.appendChild(shadowPath);
|
|
document.addEventListener("mousemove", onShadowMove);
|
|
const area = $id("draw-area");
|
|
if (area) area.classList.add("linking");
|
|
}
|
|
|
|
/** Updates the shadow path endpoint to follow the mouse. */
|
|
function onShadowMove(ev) {
|
|
if (!shadowPath) return;
|
|
const svg = shadowPath.closest("svg");
|
|
if (!svg) return;
|
|
const svgRect = svg.getBoundingClientRect();
|
|
const mx = ev.clientX - svgRect.left;
|
|
const my = ev.clientY - svgRect.top;
|
|
|
|
const hook = shadowPath._hook;
|
|
const rHook = hook.getBoundingClientRect();
|
|
const fx = (rHook.left + rHook.right) / 2 - svgRect.left;
|
|
const fy = (rHook.top + rHook.bottom) / 2 - svgRect.top;
|
|
|
|
const exit = getHookExit(hook, svgRect);
|
|
|
|
shadowPath.setAttribute("d",
|
|
`M ${fx} ${fy} L ${exit.x} ${exit.y}` +
|
|
routeBetween(exit.x, exit.y, mx, my)
|
|
);
|
|
}
|
|
|
|
/** Removes the shadow path, stops tracking the mouse, and hides empty input hooks. */
|
|
function endShadowLink() {
|
|
if (shadowPath) {
|
|
shadowPath.remove();
|
|
shadowPath = null;
|
|
}
|
|
document.removeEventListener("mousemove", onShadowMove);
|
|
const area = $id("draw-area");
|
|
if (area) area.classList.remove("linking");
|
|
}
|
|
|
|
|
|
/** Cancels any pending link operation and removes the shadow. */
|
|
function resetLinkage() {
|
|
linkageFirst = null;
|
|
endShadowLink();
|
|
}
|
|
|
|
|
|
// ── Link deletion ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Removes an SVG connection path and cleans up both blocks' flow arrays.
|
|
* @param {SVGPathElement} path - the flow path element to delete
|
|
*/
|
|
function deleteLink(path) {
|
|
if (!path.classList.contains("flow") && !path.classList.contains("flow-data")) return;
|
|
|
|
const src = path.fromElement;
|
|
const tgt = path.toElement;
|
|
|
|
if (src && src.wcNextFlow) {
|
|
const i = src.wcNextFlow.indexOf(path);
|
|
if (i !== -1) src.wcNextFlow.splice(i, 1);
|
|
}
|
|
if (tgt && tgt.wcForeFlow) {
|
|
const i = tgt.wcForeFlow.indexOf(path);
|
|
if (i !== -1) tgt.wcForeFlow.splice(i, 1);
|
|
}
|
|
|
|
if (path.toHook) {
|
|
delete path.toHook.dataset.connected;
|
|
_dataSourceCache.delete(path.toHook);
|
|
}
|
|
|
|
path.remove();
|
|
|
|
path.fromElement = null;
|
|
path.toElement = null;
|
|
path.fromHook = null;
|
|
path.toHook = null;
|
|
|
|
refreshBlockWarnings();
|
|
refreshErrorPaths();
|
|
}
|