// ── 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(); }