313 lines
11 KiB
TypeScript
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 });
|