Files
WRNexusJS/packages/ui/src/drawflow-runtime.ts
T

313 lines
11 KiB
TypeScript

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<HTMLElement, () => 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<string, { connections: [] }> = {};
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<HTMLElement>("[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<string, number>();
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<HTMLElement>("#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<string>()): 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<HTMLElement>("[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<HTMLElement>("#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<HTMLElement>("[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<HTMLElement>("[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<HTMLElement>("[data-drawflow-editor]")])
if (!root.isConnected) mounted.get(root)?.();
}
}).observe(document.documentElement, { childList: true, subtree: true });