import Drawflow from "drawflow"; type Node = { id: string; label: string; x: number; y: number; input?: boolean; ports: { key: string; label: string; target: string }[]; }; const mounted = new WeakMap void>(); /** Drawflow 0.0.60 assigns innerHTML even for an empty node. Keep its graph/interaction * engine, but construct our fixed node markup with DOM APIs under Trusted Types. */ class SafeDrawflow extends Drawflow { addWorkflowNode(n: Node): number { const id = Number(this.nodeId++), parent = document.createElement("div"), element = document.createElement("div"); parent.className = "parent-node"; element.className = "drawflow-node wrn-workflow-node"; element.id = "node-" + id; element.style.left = n.x + "px"; element.style.top = n.y + "px"; const inputs = document.createElement("div"), outputs = document.createElement("div"), content = document.createElement("div"); inputs.className = "inputs"; outputs.className = "outputs"; content.className = "drawflow_content_node"; if (n.input !== false) { const input = document.createElement("div"); input.className = "input input_1"; inputs.append(input); } const title = document.createElement("div"), label = document.createElement("strong"), key = document.createElement("small"); title.className = "wrn-drawflow-title"; label.textContent = n.label; key.textContent = n.id; title.append(label, key); content.append(title); const ports: Record = {}; n.ports.forEach((p, i) => { const output = document.createElement("div"), caption = document.createElement("div"); output.className = "output output_" + (i + 1); outputs.append(output); caption.className = "wrn-drawflow-port-label"; caption.textContent = p.label; content.append(caption); ports["output_" + (i + 1)] = { connections: [] }; }); element.append(inputs, content, outputs); parent.append(element); this.precanvas.append(parent); this.drawflow.drawflow[this.module].data[id] = { id, name: n.id, data: { workflowId: n.id }, class: "wrn-workflow-node", html: "", typenode: false, inputs: n.input === false ? {} : { input_1: { connections: [] } }, outputs: ports, pos_x: n.x, pos_y: n.y, }; this.dispatch("nodeCreated", id); return id; } } function mount(root: HTMLElement) { const host = root.querySelector("[data-drawflow-host]")!; const controls = root.closest("form") || root; const editor = new SafeDrawflow(host); editor.start(); editor.reroute = false; let nodes: Node[] = [], quiet = false, last = ""; const ids = new Map(); const byNumber = (id: number | string) => nodes.find((n) => ids.get(n.id) === Number(id)); const emit = (name: string, detail: object) => root.dispatchEvent( new CustomEvent("flow" + name, { bubbles: true, detail: { ...detail, sourceEvent: { target: root } }, }), ); const moved = () => emit("move", { nodes: nodes.map((n) => { const row = editor.getNodeFromId(ids.get(n.id)!); return { ...n, x: Math.max(0, Math.round(row.pos_x)), y: Math.max(0, Math.round(row.pos_y)), }; }), }); const fit = () => { if (!host.clientWidth || !nodes.length) return; const width = Math.max(...nodes.map((n) => editor.getNodeFromId(ids.get(n.id)!).pos_x + 250)) + 40, height = Math.max(...nodes.map((n) => editor.getNodeFromId(ids.get(n.id)!).pos_y + 180)) + 40; editor.canvas_x = 0; editor.canvas_y = 0; editor.zoom = Math.max(0.2, Math.min(1, host.clientWidth / width, host.clientHeight / height)); editor.zoom_refresh(); }; function refresh() { const raw = root.dataset.flowNodes || "[]"; if (raw === last) return; last = raw; const incoming = JSON.parse(raw) as Node[]; const fresh = incoming.filter((n) => !ids.has(n.id)); nodes = incoming; quiet = true; for (const n of fresh) ids.set(n.id, editor.addWorkflowNode(n)); for (const n of nodes) { const source = ids.get(n.id)!; const title = host.querySelector("#node-" + source + " strong"); if (title) title.textContent = n.label; for (let i = 0; i < n.ports.length; i++) { const target = ids.get(n.ports[i].target), output = "output_" + (i + 1); if (!target) continue; const connections = editor.getNodeFromId(source).outputs[output].connections; if (connections.length === 1 && Number(connections[0].node) === target) continue; for (const old of [...connections]) editor.removeSingleConnection(source, Number(old.node), output, old.output); editor.addConnection(source, target, output, "input_1"); } } quiet = false; } editor.on("nodeSelected", (id: number) => { if (!quiet) { const n = byNumber(id); if (n) emit("select", { id: n.id }); } }); editor.on("nodeMoved", () => { if (!quiet) moved(); }); editor.on( "connectionCreated", (c: { output_id: string; input_id: string; output_class: string; input_class: string }) => { if (quiet) return; const source = byNumber(c.output_id), target = byNumber(c.input_id); const index = Number(c.output_class.slice(7)) - 1; if (!source || !target || !source.ports[index]) return; const reaches = (id: string, seen = new Set()): boolean => { if (id === source.id) return true; if (seen.has(id)) return false; seen.add(id); return nodes.find((n) => n.id === id)?.ports.some((p) => reaches(p.target, seen)) || false; }; if (target.input === false || reaches(target.id)) { quiet = true; editor.removeSingleConnection( Number(c.output_id), Number(c.input_id), c.output_class, c.input_class, ); quiet = false; return; } const row = editor.getNodeFromId(Number(c.output_id)); quiet = true; for (const old of [...row.outputs[c.output_class].connections]) if (String(old.node) !== String(c.input_id)) editor.removeSingleConnection( Number(c.output_id), Number(old.node), c.output_class, old.output, ); quiet = false; source.ports[index].target = target.id; emit("connect", { source: source.id, port: source.ports[index].key, target: target.id }); }, ); // Required workflow paths are edited by connecting another destination; deletion uses the reviewed settings action. host.addEventListener( "keydown", (e) => { if (e.key === "Delete" || e.key === "Backspace") { e.stopImmediatePropagation(); e.preventDefault(); } }, true, ); host.addEventListener( "contextmenu", (e) => { e.preventDefault(); e.stopImmediatePropagation(); }, true, ); controls.addEventListener("click", (e) => { const b = (e.target as Element).closest("[data-flow-zoom],[data-flow-arrange]"); if (!b) return; if (b.hasAttribute("data-flow-arrange")) { quiet = true; nodes.forEach((n, i) => { const id = ids.get(n.id)!; const row = editor.drawflow.drawflow.Home.data[id]; row.pos_x = 40 + i * 280; row.pos_y = 60 + (i % 2) * 100; const element = host.querySelector("#node-" + id)!; element.style.left = row.pos_x + "px"; element.style.top = row.pos_y + "px"; editor.updateConnectionNodes("node-" + id); }); quiet = false; moved(); } else if (b.dataset.flowZoom === "in") editor.zoom_in(); else if (b.dataset.flowZoom === "out") editor.zoom_out(); else if (b.dataset.flowZoom === "fit") fit(); else editor.zoom_reset(); }); controls.addEventListener("dragstart", (e) => { const kind = (e.target as Element).closest("[data-flow-kind]")?.dataset.flowKind; if (kind) (e as DragEvent).dataTransfer?.setData("application/x-wrn-flow-node", kind); }); host.addEventListener("dragover", (e) => e.preventDefault()); host.addEventListener("drop", (e) => { e.preventDefault(); const kind = e.dataTransfer?.getData("application/x-wrn-flow-node"); if (kind) { const rect = host.getBoundingClientRect(); emit("add", { kind, x: Math.max(0, (e.clientX - rect.left - editor.canvas_x) / editor.zoom), y: Math.max(0, (e.clientY - rect.top - editor.canvas_y) / editor.zoom), }); } }); const form = root.closest("form"); const settingsChanged = (e: Event) => { const field = e.target as HTMLSelectElement; const match = field.name === "entry" ? ["entry", "__start", "entry"] : /^node:([^:]+):(next|timeoutNext|yes|no)$/.exec(field.name || ""); if (!match) return; const source = nodes.find((n) => n.id === match[1]); const index = source?.ports.findIndex((p) => p.key === match[2]) ?? -1; const target = ids.get(field.value); if (!source || index < 0 || !target) return; const targetNode = nodes.find((n) => n.id === field.value); if (targetNode?.input === false) return; const output = "output_" + (index + 1), id = ids.get(source.id)!; quiet = true; for (const old of [...editor.getNodeFromId(id).outputs[output].connections]) editor.removeSingleConnection(id, Number(old.node), output, old.output); editor.addConnection(id, target, output, "input_1"); source.ports[index].target = field.value; quiet = false; }; form?.addEventListener("change", settingsChanged); const observer = new MutationObserver(refresh); observer.observe(root, { attributes: true, attributeFilter: ["data-flow-nodes"] }); refresh(); let fitted = false; const resize = new ResizeObserver(() => { nodes.forEach((n) => editor.updateConnectionNodes("node-" + ids.get(n.id))); if (!fitted && host.clientWidth) { fit(); fitted = true; } }); resize.observe(host); mounted.set(root, () => { observer.disconnect(); resize.disconnect(); form?.removeEventListener("change", settingsChanged); mounted.delete(root); }); } function scan() { document.querySelectorAll("[data-drawflow-editor]").forEach((root) => { if (!mounted.has(root)) mount(root); }); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan, { once: true }); else scan(); new MutationObserver((records) => { if ( records.some((r) => Array.from(r.addedNodes).some( (n) => n instanceof Element && (n.matches("[data-drawflow-editor]") || n.querySelector("[data-drawflow-editor]")), ), ) ) scan(); for (const record of records) for (const node of record.removedNodes) if (node instanceof HTMLElement) { for (const root of [node, ...node.querySelectorAll("[data-drawflow-editor]")]) if (!root.isConnected) mounted.get(root)?.(); } }).observe(document.documentElement, { childList: true, subtree: true });