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,79 @@
|
||||
import type { ChartConfiguration, ChartData, ChartType } from "chart.js";
|
||||
|
||||
export interface ChartProps {
|
||||
title?: string;
|
||||
items?: unknown[];
|
||||
data?: unknown[] | ChartData;
|
||||
labels?: unknown[];
|
||||
datasets?: ChartData["datasets"];
|
||||
options?: Record<string, any>;
|
||||
type?: string;
|
||||
valueKey?: string;
|
||||
labelKey?: string;
|
||||
showLegend?: boolean;
|
||||
showValues?: boolean;
|
||||
}
|
||||
export const chartTypes = [
|
||||
"bar",
|
||||
"line",
|
||||
"pie",
|
||||
"doughnut",
|
||||
"radar",
|
||||
"polarArea",
|
||||
"scatter",
|
||||
"bubble",
|
||||
] as const;
|
||||
export function mergeOptions(base: any, incoming: any): any {
|
||||
const result = { ...base };
|
||||
for (const [key, value] of Object.entries(incoming ?? {})) {
|
||||
if (["__proto__", "constructor", "prototype"].includes(key)) continue;
|
||||
result[key] =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? mergeOptions(result[key], value)
|
||||
: value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function chartConfiguration(props: ChartProps): ChartConfiguration {
|
||||
const kind = props.type || "bar";
|
||||
const type = kind === "area" ? "line" : kind;
|
||||
if (!chartTypes.includes(type as ChartType)) throw new Error(`Unsupported chart type: ${kind}`);
|
||||
const native = props.data && !Array.isArray(props.data) && Array.isArray(props.data.datasets);
|
||||
const rows = Array.isArray(props.data) && props.data.length ? props.data : (props.items ?? []);
|
||||
const valueKey = props.valueKey || "value",
|
||||
labelKey = props.labelKey || "label";
|
||||
const coordinate = type === "scatter" || type === "bubble";
|
||||
const values = rows.map((row: any) => {
|
||||
if (coordinate) return row;
|
||||
const value = row && typeof row === "object" ? row[valueKey] : row;
|
||||
return value === null || value === undefined || value === "" || !Number.isFinite(Number(value))
|
||||
? null
|
||||
: Number(value);
|
||||
});
|
||||
const data = native
|
||||
? structuredClone(props.data as ChartData)
|
||||
: ({
|
||||
labels: props.labels?.length
|
||||
? structuredClone(props.labels)
|
||||
: rows.map((row: any, i) => row?.[labelKey] ?? String(i + 1)),
|
||||
datasets: props.datasets?.length
|
||||
? structuredClone(props.datasets)
|
||||
: [{ label: props.title || "Data", data: values }],
|
||||
} as ChartData);
|
||||
if (kind === "area")
|
||||
data.datasets = data.datasets.map((dataset) => ({
|
||||
...dataset,
|
||||
fill: "fill" in dataset ? dataset.fill : true,
|
||||
})) as ChartData["datasets"];
|
||||
const cartesian = ["bar", "line", "scatter", "bubble"].includes(type);
|
||||
const options = mergeOptions(
|
||||
{
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: props.showLegend !== false } },
|
||||
...(cartesian ? { scales: { y: { beginAtZero: type === "bar" } } } : {}),
|
||||
},
|
||||
props.options,
|
||||
);
|
||||
return { type: type as ChartType, data, options };
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import Chart from "chart.js/auto";
|
||||
import type { ChartConfiguration, Plugin } from "chart.js";
|
||||
import { chartConfiguration, mergeOptions, type ChartProps } from "./chart-config";
|
||||
|
||||
const selector = '[data-wrnexus-runtime~="chartjs"][data-chart-config]';
|
||||
const mounted = new Map<
|
||||
HTMLElement,
|
||||
{ chart: Chart; raw: string; kind: string; canvas: HTMLCanvasElement }
|
||||
>();
|
||||
const failures = new WeakMap<HTMLElement, string>();
|
||||
const emit = (root: HTMLElement, name: string, detail: object) =>
|
||||
root.dispatchEvent(new CustomEvent(name, { bubbles: true, detail }));
|
||||
const radial = (type: string) => ["pie", "doughnut", "polarArea"].includes(type);
|
||||
|
||||
const valueLabels: Plugin = {
|
||||
id: "wrnValues",
|
||||
afterDatasetsDraw(chart) {
|
||||
const root = chart.canvas.closest<HTMLElement>(selector);
|
||||
if (!root || JSON.parse(root.dataset.chartConfig || "{}").showValues === false) return;
|
||||
// Leave dense plots and arcs to Chart.js tooltips; label bars and line points.
|
||||
if (!["bar", "line"].includes((chart.config as ChartConfiguration).type)) return;
|
||||
const { ctx, chartArea } = chart;
|
||||
ctx.save();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.fillStyle = (chart.options.color as string) || "#666";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "bottom";
|
||||
for (const meta of chart.getSortedVisibleDatasetMetas()) {
|
||||
if (meta.data.length > 24) continue;
|
||||
meta.data.forEach((element, index) => {
|
||||
const value: any = chart.data.datasets[meta.index].data[index];
|
||||
if (value === null || (element as any).hidden || (element as any).skip) return;
|
||||
const position = element.tooltipPosition(true);
|
||||
if (position.x === null || position.y === null) return;
|
||||
const label = typeof value === "object" ? String(value.y ?? "") : String(value);
|
||||
ctx.fillText(
|
||||
label,
|
||||
Math.max(chartArea.left + 12, Math.min(chartArea.right - 12, position.x)),
|
||||
Math.max(chartArea.top + 12, position.y - 5),
|
||||
);
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
function accessibleTable(root: HTMLElement, config: ChartConfiguration) {
|
||||
const host = root.querySelector<HTMLElement>("[data-chart-table]");
|
||||
if (!host) return;
|
||||
const table = document.createElement("table"),
|
||||
caption = document.createElement("caption");
|
||||
caption.textContent = root.querySelector("canvas")?.getAttribute("aria-label") || "Chart data";
|
||||
table.append(caption);
|
||||
const header = table.createTHead().insertRow();
|
||||
for (const text of ["Label", ...config.data.datasets.map((d) => d.label || "Data")]) {
|
||||
const th = document.createElement("th");
|
||||
th.scope = "col";
|
||||
th.textContent = text;
|
||||
header.append(th);
|
||||
}
|
||||
const body = table.createTBody();
|
||||
const count = Math.max(0, ...config.data.datasets.map((d) => d.data.length));
|
||||
for (let i = 0; i < count; i++) {
|
||||
const row = body.insertRow(),
|
||||
label = document.createElement("th");
|
||||
label.scope = "row";
|
||||
label.textContent = String(config.data.labels?.[i] ?? i + 1);
|
||||
row.append(label);
|
||||
for (const dataset of config.data.datasets) {
|
||||
const value = dataset.data[i];
|
||||
row.insertCell().textContent =
|
||||
value == null ? "—" : typeof value === "object" ? JSON.stringify(value) : String(value);
|
||||
}
|
||||
}
|
||||
host.replaceChildren(table);
|
||||
}
|
||||
function configuration(root: HTMLElement, props: ChartProps): ChartConfiguration {
|
||||
const config = chartConfiguration(props),
|
||||
style = getComputedStyle(root);
|
||||
const text = style.color || "#666",
|
||||
grid = style.getPropertyValue("--wrn-color-border").trim() || "rgba(0,0,0,.1)";
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||
config.options = mergeOptions(
|
||||
{
|
||||
color: text,
|
||||
font: { family: style.fontFamily },
|
||||
...(reduced ? { animation: false } : {}),
|
||||
...(["bar", "line", "scatter", "bubble"].includes(config.type)
|
||||
? {
|
||||
scales: {
|
||||
x: { ticks: { color: text }, grid: { color: grid } },
|
||||
y: { ticks: { color: text }, grid: { color: grid } },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
config.options,
|
||||
);
|
||||
config.options!.onClick = (event, elements, chart) => {
|
||||
if (!elements.length) return;
|
||||
const { index, datasetIndex } = elements[0];
|
||||
const rows = Array.isArray(props.data) && props.data.length ? props.data : props.items;
|
||||
const item =
|
||||
(!Array.isArray(props.data) && props.data?.datasets) || props.datasets?.length
|
||||
? chart.data.datasets[datasetIndex].data[index]
|
||||
: rows?.[index];
|
||||
const detail = { item, index, datasetIndex, sourceEvent: event.native };
|
||||
emit(root, "chartpointclick", detail);
|
||||
emit(root, "chartselect", detail);
|
||||
};
|
||||
const plugins = config.options!.plugins;
|
||||
if (plugins && typeof plugins === "object" && (plugins.legend as unknown) !== false) {
|
||||
const legend = (plugins!.legend ??= {});
|
||||
legend.onClick = (event, item, context) => {
|
||||
const chart = context.chart,
|
||||
arc = radial(config.type),
|
||||
index = arc ? item.index! : item.datasetIndex!;
|
||||
const defaultHandler =
|
||||
Chart.overrides[config.type]?.plugins?.legend?.onClick ??
|
||||
Chart.defaults.plugins.legend.onClick;
|
||||
defaultHandler.call(context, event, item, context);
|
||||
emit(root, "chartlegendtoggle", {
|
||||
item: arc ? chart.data.datasets[0].data[index] : chart.data.datasets[index],
|
||||
index,
|
||||
datasetIndex: arc ? 0 : index,
|
||||
hidden: arc ? !chart.getDataVisibility(index) : !chart.isDatasetVisible(index),
|
||||
sourceEvent: event.native,
|
||||
});
|
||||
};
|
||||
}
|
||||
config.plugins = [valueLabels];
|
||||
return config;
|
||||
}
|
||||
function refresh(root: HTMLElement, force = false) {
|
||||
const canvas = root.querySelector<HTMLCanvasElement>("[data-chart-canvas]");
|
||||
if (!canvas) return;
|
||||
const raw = root.dataset.chartConfig || "{}",
|
||||
previous = mounted.get(root);
|
||||
if (
|
||||
!force &&
|
||||
((previous?.raw === raw && previous.canvas === canvas) || failures.get(root) === raw)
|
||||
)
|
||||
return;
|
||||
const status = root.querySelector<HTMLElement>("[data-chart-status]");
|
||||
try {
|
||||
const props = JSON.parse(raw) as ChartProps,
|
||||
config = configuration(root, props);
|
||||
accessibleTable(root, config);
|
||||
if (previous && previous.kind === (props.type || "bar") && previous.canvas === canvas) {
|
||||
previous.chart.data = config.data;
|
||||
previous.chart.options = config.options!;
|
||||
previous.chart.update();
|
||||
previous.raw = raw;
|
||||
} else {
|
||||
previous?.chart.destroy();
|
||||
mounted.delete(root);
|
||||
const chart = new Chart(canvas, config);
|
||||
mounted.set(root, { chart, raw, kind: props.type || "bar", canvas });
|
||||
}
|
||||
failures.delete(root);
|
||||
if (status)
|
||||
status.textContent = config.data.datasets.some((d) => d.data.some((v) => v !== null))
|
||||
? ""
|
||||
: "No data to show";
|
||||
} catch (error) {
|
||||
failures.set(root, raw);
|
||||
Chart.getChart(canvas)?.destroy();
|
||||
mounted.delete(root);
|
||||
if (status)
|
||||
status.textContent = error instanceof Error ? error.message : "Unable to display chart";
|
||||
}
|
||||
}
|
||||
export function startChartRuntime() {
|
||||
const scan = () => {
|
||||
for (const [root, state] of mounted)
|
||||
if (!root.isConnected) {
|
||||
state.chart.destroy();
|
||||
mounted.delete(root);
|
||||
}
|
||||
document.querySelectorAll<HTMLElement>(selector).forEach((root) => refresh(root));
|
||||
};
|
||||
scan();
|
||||
const themed = new Set<HTMLElement>();
|
||||
let themeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const refreshTheme = (root: HTMLElement) => {
|
||||
refresh(root, true);
|
||||
themed.add(root);
|
||||
clearTimeout(themeTimer);
|
||||
// Theme CSS can transition color after the attribute mutation. Read the
|
||||
// settled color as well, so unchanged charts do not retain the old palette.
|
||||
themeTimer = setTimeout(() => {
|
||||
for (const chart of themed) if (chart.isConnected) refresh(chart, true);
|
||||
themed.clear();
|
||||
}, 250);
|
||||
};
|
||||
const finishTransition = (event: TransitionEvent) => {
|
||||
if (event.propertyName !== "color" || !(event.target instanceof HTMLElement)) return;
|
||||
for (const root of mounted.keys())
|
||||
if (event.target === root || event.target.contains(root)) refresh(root, true);
|
||||
};
|
||||
document.addEventListener("transitionend", finishTransition);
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (records.some((r) => r.type === "childList")) scan();
|
||||
for (const record of records)
|
||||
if (record.type === "attributes" && record.target instanceof HTMLElement) {
|
||||
if (record.target.matches(selector)) {
|
||||
if (record.attributeName === "data-chart-config") refresh(record.target);
|
||||
else refreshTheme(record.target);
|
||||
} else if (["class", "data-theme", "style"].includes(record.attributeName || "")) {
|
||||
record.target.querySelectorAll<HTMLElement>(selector).forEach(refreshTheme);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-chart-config", "data-color", "data-theme", "class", "style"],
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(themeTimer);
|
||||
themed.clear();
|
||||
document.removeEventListener("transitionend", finishTransition);
|
||||
for (const state of mounted.values()) state.chart.destroy();
|
||||
mounted.clear();
|
||||
};
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
if (document.readyState === "loading")
|
||||
document.addEventListener("DOMContentLoaded", () => startChartRuntime(), { once: true });
|
||||
else startChartRuntime();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Optional, browser-safe metadata entry; default UI imports stay unchanged. */
|
||||
export {
|
||||
CORE_COMPONENT_CONTRACTS,
|
||||
COMPONENT_CONTRACTS,
|
||||
CATALOG_COMPONENT_CONTRACTS,
|
||||
componentDesignContract,
|
||||
} from "@wrnexus/design-system";
|
||||
export type { ComponentDesignContract } from "@wrnexus/design-system";
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { definePlugin } from "@wrnexus/plugin";
|
||||
export function drawflowPlugin() {
|
||||
return definePlugin({
|
||||
name: "@wrnexus/ui/drawflow",
|
||||
clientRuntimes: [
|
||||
{
|
||||
id: "drawflow",
|
||||
entry: fileURLToPath(
|
||||
new URL(
|
||||
import.meta.url.endsWith(".js") ? "./drawflow-runtime.js" : "./drawflow-runtime.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
bundle: true,
|
||||
},
|
||||
],
|
||||
styleSources: [
|
||||
{
|
||||
id: "drawflow-vendor",
|
||||
entry: createRequire(import.meta.url).resolve("drawflow/dist/drawflow.min.css"),
|
||||
},
|
||||
{ id: "drawflow", entry: fileURLToPath(new URL("../drawflow.css", import.meta.url)) },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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