feat: release design system engine, studio and component contracts
This commit is contained in:
1 parent
ff1065fb27
commit
3943b946d6
693 files changed
+175546
-3894
No files matched your search
@@ -0,0 +1,312 @@
|
||||
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 });
|
||||
Reference in new issue
Block a user