diff --git a/web/package.json b/web/package.json
index 1f1fc397..2e253f79 100644
--- a/web/package.json
+++ b/web/package.json
@@ -62,6 +62,7 @@
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"tailwindcss": "^4.1.14",
+ "x6-html-shape": "0.4.9",
"xlsx": "^0.18.5",
"zustand": "^5.0.8"
},
diff --git a/web/src/vendor/x6-html-shape/index.js b/web/src/vendor/x6-html-shape/index.js
new file mode 100644
index 00000000..f1b976d1
--- /dev/null
+++ b/web/src/vendor/x6-html-shape/index.js
@@ -0,0 +1,176 @@
+// Patched x6-html-shape: replaces View.createElement (removed in X6 3.x) with document.createElement
+import { Node as p, NodeView as l, Graph as C, Dom as s } from "@antv/x6";
+import { getConfig as w, clickable as x, isInputElement as y, forwardEvent as S } from "./utils.js";
+
+const u = "html-shape", h = "html-shape-view", T = p.define(w(h)), m = {};
+
+export function register(i) {
+ const { shape: e, render: n, inherit: t = u, ...o } = i;
+ if (!e) throw new Error("should specify shape in config");
+ m[e] = n;
+ C.registerNode(e, { inherit: t, ...o }, true);
+}
+
+const a = "html";
+
+// Determine which HTML layer a node belongs to.
+// Parent (loop/iteration) nodes go behind the SVG layer so edges render above them.
+// All other nodes go in front of the SVG layer so they render above edges.
+function isBackNode(cell) {
+ const type = cell.getData?.()?.type;
+ return type === 'loop' || type === 'iteration';
+}
+
+// Ensure the two HTML container layers exist and are correctly positioned.
+function ensureHtmlLayers(graph) {
+ if (!graph._htmlBack) {
+ const back = graph._htmlBack = document.createElement('div');
+ s.css(back, {
+ position: 'absolute', width: '100%', height: '100%',
+ 'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
+ 'z-index': 0, 'transform-origin': 'left top',
+ });
+ back.classList.add('x6-html-shape-container', 'x6-html-shape-back');
+ const svg = graph.container.querySelector('svg');
+ // back layer: before SVG → visually behind edges
+ graph.container.insertBefore(back, svg || null);
+ }
+ if (!graph._htmlFront) {
+ const front = graph._htmlFront = document.createElement('div');
+ s.css(front, {
+ position: 'absolute', width: '100%', height: '100%',
+ 'touch-action': 'none', 'user-select': 'none', 'pointer-events': 'none',
+ 'z-index': 0, 'transform-origin': 'left top',
+ });
+ front.classList.add('x6-html-shape-container', 'x6-html-shape-front');
+ // front layer: after SVG → visually above edges
+ graph.container.append(front);
+ }
+ // Keep legacy alias so updateHtmlContainerSize can iterate both
+ graph.htmlContainers = [graph._htmlBack, graph._htmlFront];
+}
+
+class BaseHTMLShapeView extends l {
+ confirmUpdate(e) {
+ const n = super.confirmUpdate(e);
+ return this.handleAction(n, a, () => {
+ if (!this.mounted) {
+ const t = m[this.cell.shape], o = this.ensureComponentContainer();
+ t && o && (this.mounted = t(this.cell, this.graph, o) || true,
+ this.onMounted(),
+ o.addEventListener("mousedown", this.prevEvent, true),
+ o.addEventListener("mouseup", this.prevEvent, true));
+ }
+ });
+ }
+ prevEvent(e) {
+ (x(e.target) || y(e.target)) && (e.preventDefault(), e.stopPropagation());
+ }
+ ensureComponentContainer() {}
+ onMounted() {}
+ onUnMount() {
+ if (this.onZIndexChange) {
+ this.cell.off("change:zIndex", this.onZIndexChange);
+ }
+ if (this.onNodeMoving) {
+ this.graph.off("node:moving", this.onNodeMoving);
+ }
+ }
+ unmount() {
+ typeof this.mounted == "function" && this.mounted();
+ this.componentContainer && this.componentContainer.remove();
+ this.onUnMount();
+ return super.unmount(), this;
+ }
+}
+
+BaseHTMLShapeView.config({ bootstrap: [a], actions: { component: a } });
+
+class HTMLShapeView extends BaseHTMLShapeView {
+ constructor(...e) {
+ super(...e);
+ this.cell.on("change:visible", ({ cell: n }) => {
+ if (n.view === h) {
+ const t = this.graph.findViewByCell(n.id);
+ t && Promise.resolve().then(() => {
+ t.componentContainer.style.display = t.container.style.display;
+ });
+ }
+ });
+ }
+ onMounted() {
+ const listeners = this.graph.listeners;
+ // Always register per-cell zIndex listener regardless of shared transform events
+ this.onZIndexChange = () => this.updateContainerStyle();
+ this.cell.on("change:zIndex", this.onZIndexChange);
+ if (listeners?.hasTransformEvent?.length) return;
+ this.onTranslate = this.updateHtmlContainerSize.bind(this);
+ this.graph.on("translate", this.onTranslate);
+ this.graph.on("scale", this.onTranslate);
+ this.graph.on("node:change:position", this.onTranslate);
+ this.graph.on("hasTransformEvent", this.onTranslate);
+ // While dragging, lift this node's componentContainer to the top of its
+ // layer so its ports are never obscured by a sibling node underneath.
+ this.onNodeMoving = ({ node }) => {
+ if (node === this.cell && this.componentContainer) {
+ const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
+ layer.append(this.componentContainer);
+ }
+ };
+ this.graph.on("node:moving", this.onNodeMoving);
+ this.updateHtmlContainerSize();
+ }
+ ensureComponentContainer() {
+ ensureHtmlLayers(this.graph);
+ const layer = isBackNode(this.cell) ? this.graph._htmlBack : this.graph._htmlFront;
+ if (!this.componentContainer) {
+ const e = this.componentContainer = document.createElement("div");
+ s.css(e, {
+ "pointer-events": "auto", "touch-action": "none", "user-select": "none",
+ "transform-origin": "center", position: "absolute"
+ });
+ e.classList.add("x6-html-shape-node");
+ "click,dblclick,contextmenu,mousedown,mousemove,mouseup,mouseover,mouseout,mouseenter,mouseleave"
+ .split(",").forEach(t => S(t, e, this.container));
+ layer.append(e);
+ }
+ return this.componentContainer;
+ }
+ resize() { super.resize(); this.updateContainerStyle(); }
+ updateTransform() { super.updateTransform(); this.updateContainerStyle(); }
+ updateContainerStyle() {
+ const e = this.ensureComponentContainer();
+ const { x: n, y: t } = this.cell.getBBox();
+ const { width: o, height: r } = this.cell.getSize();
+ const g = getComputedStyle(this.container).cursor;
+ const f = this.cell.getZIndex() ?? 0;
+ // Shrink the interactive width by the port hover radius (6px) so the right
+ // port circle is fully outside the componentContainer and never blocked by it.
+ // overflow:visible keeps the visual rendering intact.
+ const PORT_RADIUS = 6;
+ s.css(e, {
+ cursor: g, height: r + "px", width: (o - PORT_RADIUS) + "px",
+ overflow: "visible",
+ "z-index": f,
+ transform: `translate(${n}px, ${t}px) rotate(${this.cell.getAngle()}deg)`
+ });
+ }
+ updateHtmlContainerSize() {
+ const { graph: e } = this;
+ const t = e.transform.getMatrix();
+ const { offsetHeight: o, offsetWidth: r } = e.container;
+ const n = e.transform.getZoom();
+ const style = {
+ transform: `matrix(${t.a}, ${t.b}, ${t.c}, ${t.d}, ${t.e}, ${t.f})`,
+ width: r / n + "px",
+ height: o / n + "px",
+ };
+ // Update both layers
+ (e.htmlContainers || [e._htmlBack, e._htmlFront].filter(Boolean)).forEach(c => s.css(c, style));
+ }
+}
+
+l.registry.register(h, HTMLShapeView, true);
+p.registry.register(u, T, true);
+
+export { BaseHTMLShapeView, T as HTMLShape, u as HTMLShapeName, HTMLShapeView, h as HTMLView, a as action };
diff --git a/web/src/vendor/x6-html-shape/react.js b/web/src/vendor/x6-html-shape/react.js
new file mode 100644
index 00000000..55a3d5f9
--- /dev/null
+++ b/web/src/vendor/x6-html-shape/react.js
@@ -0,0 +1 @@
+export { default } from "x6-html-shape/dist/react.js";
diff --git a/web/src/vendor/x6-html-shape/utils.js b/web/src/vendor/x6-html-shape/utils.js
new file mode 100644
index 00000000..f6bc529b
--- /dev/null
+++ b/web/src/vendor/x6-html-shape/utils.js
@@ -0,0 +1,98 @@
+import { Dom as u, ObjectExt as l, Markup as c } from "@antv/x6";
+const o = "fo-shape-view";
+function p(t, e, r) {
+ e.addEventListener(t, function(n) {
+ r.dispatchEvent(new n.constructor(n.type, n)), n.preventDefault(), n.stopPropagation();
+ });
+}
+function s(t, e = 3) {
+ return !t || !u.isHTMLElement(t) || e <= 0 ? !1 : ["a", "button"].includes(u.tagName(t)) || t.getAttribute("role") === "button" || t.getAttribute("type") === "button" ? !0 : s(t.parentNode, e - 1);
+}
+function g(t) {
+ if (u.tagName(t) === "input") {
+ const r = t.getAttribute("type");
+ if (r == null || ["text", "password", "number", "email", "search", "tel", "url"].includes(
+ r
+ ))
+ return !0;
+ }
+ return !1;
+}
+function f(t = "rect", e = !0) {
+ return [
+ {
+ tagName: t,
+ selector: "body"
+ },
+ e ? c.getForeignObjectMarkup() : null,
+ {
+ tagName: "text",
+ selector: "label"
+ }
+ ].filter((r) => r);
+}
+function b(t) {
+ return {
+ view: t,
+ markup: f("rect", t === o),
+ attrs: {
+ body: {
+ // fill: "none",
+ // 这里很奇怪,none的时候不能触发节点移动,改成transparent可以触发
+ fill: "transparent",
+ stroke: "none",
+ refWidth: "100%",
+ refHeight: "100%"
+ },
+ label: {
+ fontSize: 14,
+ fill: "#333",
+ refX: "50%",
+ refY: "50%",
+ textAnchor: "middle",
+ textVerticalAnchor: "middle"
+ },
+ fo: {
+ refWidth: "100%",
+ refHeight: "100%"
+ }
+ },
+ propHooks(e) {
+ if (e.markup == null) {
+ const { primer: r, view: n } = e;
+ if (r && r !== "rect") {
+ e.markup = f(r, n === o);
+ let i = {};
+ r === "circle" ? i = {
+ refCx: "50%",
+ refCy: "50%",
+ refR: "50%"
+ } : r === "ellipse" && (i = {
+ refCx: "50%",
+ refCy: "50%",
+ refRx: "50%",
+ refRy: "50%"
+ }), e.attrs = l.merge(
+ {},
+ {
+ body: {
+ refWidth: null,
+ refHeight: null,
+ ...i
+ }
+ },
+ e.attrs || {}
+ );
+ }
+ }
+ return e;
+ }
+ };
+}
+export {
+ o as FOView,
+ s as clickable,
+ p as forwardEvent,
+ b as getConfig,
+ g as isInputElement
+};
diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx
index 3bdb96c0..542b8c35 100644
--- a/web/src/views/Workflow/components/Nodes/AddNode.tsx
+++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx
@@ -2,197 +2,29 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:31:30
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-30 11:55:10
+ * @Last Modified time: 2026-04-28 10:24:58
*/
-import { useState } from 'react';
-import { Popover, Flex } from 'antd';
+import { Flex } from 'antd';
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
-import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../../constant';
-import { useTranslation } from 'react-i18next';
-const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
+const AddNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {};
- const { t } = useTranslation();
- const [open, setOpen] = useState(false);
-
- // Handle node selection from popover and create new node replacing the add-node placeholder
- const handleNodeSelect = (selectedNodeType: any) => {
- const parentBBox = node.getBBox();
- const cycleId = data.cycle;
- const horizontalSpacing = 0;
-
- const id = `${selectedNodeType.type.replace(/-/g, '_') }_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
- const newNode = graph.addNode({
- ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
- x: parentBBox.x + horizontalSpacing,
- y: parentBBox.y - 12,
- id,
- data: {
- id,
- type: selectedNodeType.type,
- icon: selectedNodeType.icon,
- name: t(`workflow.${selectedNodeType.type}`),
- cycle: cycleId,
- parentId: data.parentId,
- config: selectedNodeType.config || {}
- },
- });
-
- // Add new node as child of parent node
- if (cycleId) {
- const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
- if (parentNode) {
- parentNode.addChild(newNode);
- }
- }
-
- const incomingEdges = graph.getIncomingEdges(node);
- const outgoingEdges = graph.getOutgoingEdges(node);
- const addedEdges: any[] = [];
-
- incomingEdges?.forEach((edge: any) => {
- addedEdges.push(graph.addEdge({
- source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
- target: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'left')?.id || 'left' },
- ...edgeAttrs
- }));
- });
-
- outgoingEdges?.forEach((edge: any) => {
- const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
- const targetPortId = targetCell?.getPorts?.()?.find((port: any) => port.group === 'left')?.id || edge.getTargetPortId();
- addedEdges.push(graph.addEdge({
- source: { cell: newNode.id, port: newNode.getPorts().find((port: any) => port.group === 'right')?.id || 'right' },
- target: { cell: edge.getTargetCellId(), port: targetPortId },
- ...edgeAttrs
- }));
- });
-
- // Remove all add-node type nodes
- graph.getNodes().forEach((n: any) => {
- if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) {
- n.remove();
- }
- });
-
- setTimeout(() => {
- addedEdges.forEach(e => {
- const src = graph.getCellById(e.getSourceCellId());
- const tgt = graph.getCellById(e.getTargetCellId());
- if (src?.isNode()) src.toFront();
- if (tgt?.isNode()) tgt.toFront();
- });
- }, 50);
-
- // Automatically adjust loop node size
- const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
- if (loopNode) {
- const adjustLoopSize = () => {
- const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
- if (childNodes.length > 0) {
- const bounds = childNodes.reduce((acc, child) => {
- const bbox = child.getBBox();
- return {
- minX: Math.min(acc.minX, bbox.x),
- minY: Math.min(acc.minY, bbox.y),
- maxX: Math.max(acc.maxX, bbox.x + bbox.width),
- maxY: Math.max(acc.maxY, bbox.y + bbox.height)
- };
- }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
-
- const padding = 50;
- const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
- const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
-
- loopNode.prop('size', { width: newWidth, height: newHeight });
-
- // Update right port x position
- const ports = loopNode.getPorts();
- ports.forEach(port => {
- if (port.group === 'right' && port.args) {
- loopNode.portProp(port.id!, 'args/x', newWidth);
- }
- });
- }
- };
-
- adjustLoopSize();
-
- // Listen to child node movement events
- const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
- childNodes.forEach((childNode: any) => {
- childNode.on('change:position', adjustLoopSize);
- });
- }
- setOpen(false);
- };
-
- const content = (
-
- {nodeLibrary.map((category, categoryIndex) => {
- const filteredNodes = category.nodes.filter(nodeType =>
- nodeType.type !== 'start' && nodeType.type !== 'end' && nodeType.type !== 'iteration' && nodeType.type !== 'loop' && nodeType.type !== 'cycle-start'
- );
-
- if (filteredNodes.length === 0) return null;
-
- return (
-
- {categoryIndex > 0 &&
}
-
- {t(`workflow.${category.category}`)}
-
- {filteredNodes.map((nodeType) => (
-
handleNodeSelect(nodeType)}
- onMouseEnter={(e) => {
- e.currentTarget.style.background = '#f0f8ff';
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = 'white';
- }}
- >
-
-
{t(`workflow.${nodeType.type}`)}
-
- ))}
-
- );
- })}
-
- );
return (
-
-
-
- {data.label}
-
-
+
+ {data.label}
+
);
};
-export default AddNode;
\ No newline at end of file
+export default AddNode;
diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx
index cb3e16c4..a2ebed44 100644
--- a/web/src/views/Workflow/components/PortClickHandler.tsx
+++ b/web/src/views/Workflow/components/PortClickHandler.tsx
@@ -2,13 +2,44 @@
* @Author: ZhaoYing
* @Date: 2026-02-09 18:30:28
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-03-30 15:14:02
+ * @Last Modified time: 2026-04-28 11:41:17
*/
import { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
import { Flex, Popover } from 'antd';
import { useTranslation } from 'react-i18next';
import { nodeLibrary, graphNodeLibrary, edgeAttrs, nodeWidth } from '../constant';
+// Shared helper: adjust loop/iteration container size to fit child nodes
+export const adjustCycleContainerSize = (graph: any, cycleId: string) => {
+ const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
+ if (!parentNode) return;
+ const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
+ if (childNodes.length === 0) return;
+ const bounds = childNodes.reduce((acc: any, child: any) => {
+ const bbox = child.getBBox();
+ return {
+ minX: Math.min(acc.minX, bbox.x),
+ minY: Math.min(acc.minY, bbox.y),
+ maxX: Math.max(acc.maxX, bbox.x + bbox.width),
+ maxY: Math.max(acc.maxY, bbox.y + bbox.height),
+ };
+ }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
+ const padding = 50;
+ const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
+ const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
+ parentNode.prop('size', { width: newWidth, height: newHeight });
+ parentNode.getPorts().forEach((port: any) => {
+ if (port.group === 'right' && port.args) {
+ parentNode.portProp(port.id!, 'args/x', newWidth);
+ }
+ });
+ childNodes.forEach((childNode: any) => {
+ childNode.off('change:position');
+ childNode.on('change:position', () => adjustCycleContainerSize(graph, cycleId));
+ });
+};
+
interface PortClickHandlerProps {
graph: any;
}
@@ -16,7 +47,6 @@ interface PortClickHandlerProps {
const PortClickHandler: React.FC = ({ graph }) => {
const { t } = useTranslation();
const [popoverVisible, setPopoverVisible] = useState(false);
- const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
const [sourceNode, setSourceNode] = useState(null);
const [sourcePort, setSourcePort] = useState('');
const [tempElement, setTempElement] = useState(null);
@@ -24,12 +54,11 @@ const PortClickHandler: React.FC = ({ graph }) => {
useEffect(() => {
const handlePortClick = (event: CustomEvent) => {
- const { node, port, element, rect, edgeInsertion } = event.detail;
+ const { node, port, element, edgeInsertion } = event.detail;
setSourceNode(node);
setSourcePort(port);
setTempElement(element);
setEdgeInsertion(edgeInsertion || null);
- setPopoverPosition({ x: rect.left, y: rect.top });
setPopoverVisible(true);
};
@@ -49,6 +78,66 @@ const PortClickHandler: React.FC = ({ graph }) => {
const sourceNodeData = sourceNode.getData();
const sourceNodeType = sourceNodeData?.type;
+
+ // AddNode placeholder mode: replace the add-node placeholder with the selected node
+ if (sourceNodeType === 'add-node') {
+ const placeholderBBox = sourceNode.getBBox();
+ const cycleId = sourceNodeData.cycle;
+ const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
+ const newNode = graph.addNode({
+ ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
+ x: placeholderBBox.x,
+ y: placeholderBBox.y - 12,
+ id,
+ data: {
+ id,
+ type: selectedNodeType.type,
+ icon: selectedNodeType.icon,
+ name: t(`workflow.${selectedNodeType.type}`),
+ cycle: cycleId,
+ parentId: sourceNodeData.parentId,
+ config: selectedNodeType.config || {},
+ },
+ });
+ if (cycleId) {
+ const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
+ if (parentNode) parentNode.addChild(newNode);
+ }
+ const incomingEdges = graph.getIncomingEdges(sourceNode);
+ const outgoingEdges = graph.getOutgoingEdges(sourceNode);
+ const addedEdges: any[] = [];
+ incomingEdges?.forEach((edge: any) => {
+ addedEdges.push(graph.addEdge({
+ source: { cell: edge.getSourceCellId(), port: edge.getSourcePortId() },
+ target: { cell: newNode.id, port: newNode.getPorts().find((p: any) => p.group === 'left')?.id || 'left' },
+ ...edgeAttrs,
+ }));
+ });
+ outgoingEdges?.forEach((edge: any) => {
+ const targetCell = graph.getCellById(edge.getTargetCellId()) as any;
+ const targetPortId = targetCell?.getPorts?.()?.find((p: any) => p.group === 'left')?.id || edge.getTargetPortId();
+ addedEdges.push(graph.addEdge({
+ source: { cell: newNode.id, port: newNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
+ target: { cell: edge.getTargetCellId(), port: targetPortId },
+ ...edgeAttrs,
+ }));
+ });
+ graph.getNodes().forEach((n: any) => {
+ if (n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) n.remove();
+ });
+ setTimeout(() => {
+ addedEdges.forEach(e => {
+ const src = graph.getCellById(e.getSourceCellId());
+ const tgt = graph.getCellById(e.getTargetCellId());
+ if (src?.isNode()) src.toFront();
+ if (tgt?.isNode()) tgt.toFront();
+ });
+ }, 50);
+ if (cycleId) adjustCycleContainerSize(graph, cycleId);
+ if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); }
+ setPopoverVisible(false);
+ return;
+ }
// If it's a cycle-start node, handle the add-node placeholder
let addNodePosition = null;
@@ -228,48 +317,7 @@ const PortClickHandler: React.FC = ({ graph }) => {
// Adjust loop node size when child node is added via port within loop node
const cycleId = sourceNodeData.cycle;
- if (cycleId) {
- const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
-
- if (parentNode) {
- const adjustLoopSize = () => {
- const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
- if (childNodes.length > 0) {
- const bounds = childNodes.reduce((acc: any, child: any) => {
- const bbox = child.getBBox();
- return {
- minX: Math.min(acc.minX, bbox.x),
- minY: Math.min(acc.minY, bbox.y),
- maxX: Math.max(acc.maxX, bbox.x + bbox.width),
- maxY: Math.max(acc.maxY, bbox.y + bbox.height)
- };
- }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
-
- const padding = 50;
- const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
- const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
-
- parentNode.prop('size', { width: newWidth, height: newHeight });
-
- // Update right port x position
- const ports = parentNode.getPorts();
- ports.forEach((port: any) => {
- if (port.group === 'right' && port.args) {
- parentNode.portProp(port.id!, 'args/x', newWidth);
- }
- });
- }
- };
-
- adjustLoopSize();
-
- // Listen to child node movement events
- const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
- childNodes.forEach((childNode: any) => {
- childNode.on('change:position', adjustLoopSize);
- });
- }
- }
+ if (cycleId) adjustCycleContainerSize(graph, cycleId);
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
const newNodeType = selectedNodeType.type;
@@ -372,22 +420,18 @@ const PortClickHandler: React.FC = ({ graph }) => {
if (!tempElement) return null;
- return (
+ return createPortal(
{
- if (!visible) handlePopoverClose();
- }}
+ onOpenChange={(visible) => { if (!visible) handlePopoverClose(); }}
placement="right"
- overlayStyle={{
- position: 'fixed',
- left: popoverPosition.x + 10,
- top: popoverPosition.y - 10,
- }}
+ autoAdjustOverflow
+ getPopupContainer={() => document.body}
>
-
-
+
+ ,
+ tempElement
);
};
diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
index f30db10f..72309f2a 100644
--- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts
+++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts
@@ -2,14 +2,16 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing
- * @Last Modified time: 2026-04-20 16:00:26
+ * @Last Modified time: 2026-04-28 12:07:33
*/
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type';
-import { register } from '@antv/x6-react-shape';
+import { register as registerReactShape } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port';
import { App } from 'antd';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, createElement } from 'react';
+import type { RefObject, Dispatch, SetStateAction, MutableRefObject, DragEvent } from 'react';
+import { createRoot } from 'react-dom/client';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
@@ -21,14 +23,16 @@ import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types';
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
import { useWorkflowStore } from '@/store/workflow';
+const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+
/**
* Props for useWorkflowGraph hook
*/
export interface UseWorkflowGraphProps {
/** Reference to the main graph container element */
- containerRef: React.RefObject;
+ containerRef: RefObject;
/** Reference to the minimap container element */
- miniMapRef: React.RefObject;
+ miniMapRef: RefObject;
/** Callback when features config is loaded */
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
}
@@ -40,23 +44,23 @@ export interface UseWorkflowGraphReturn {
/** Current workflow configuration */
config: WorkflowConfig | null;
/** Function to update workflow configuration */
- setConfig: React.Dispatch>;
+ setConfig: Dispatch>;
/** Reference to the X6 graph instance */
- graphRef: React.MutableRefObject;
+ graphRef: MutableRefObject;
/** Currently selected node */
selectedNode: Node | null;
/** Function to update selected node */
- setSelectedNode: React.Dispatch>;
+ setSelectedNode: Dispatch>;
/** Current zoom level of the graph */
zoomLevel: number;
/** Function to update zoom level */
- setZoomLevel: React.Dispatch>;
+ setZoomLevel: Dispatch>;
/** Whether hand/pan mode is enabled */
isHandMode: boolean;
/** Function to toggle hand mode */
- setIsHandMode: React.Dispatch>;
+ setIsHandMode: Dispatch>;
/** Handler for dropping nodes onto canvas */
- onDrop: (event: React.DragEvent) => void;
+ onDrop: (event: DragEvent) => void;
/** Handler for clicking blank canvas area */
blankClick: () => void;
/** Handler for delete keyboard event */
@@ -78,7 +82,7 @@ export interface UseWorkflowGraphReturn {
/** Chat variables for workflow */
chatVariables: ChatVariable[];
/** Function to update chat variables */
- setChatVariables: React.Dispatch>;
+ setChatVariables: Dispatch>;
handleAddNotes: () => void;
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
@@ -160,6 +164,21 @@ export const useWorkflowGraph = ({
initWorkflow()
}, [config, graphRef.current])
+ /**
+ * Assign explicit zIndex values to enforce layer order:
+ * parent nodes (loop/iteration) → child edges → child nodes
+ * Ports live inside each node's SVG container and are always above
+ * edges once the node zIndex is higher than the edge zIndex.
+ */
+ const reorderCells = (graph: Graph) => {
+ // Safari uses x6-html-shape (dual HTML layer architecture).
+ // zIndex controls order within each HTML layer and SVG layer.
+ graph.getEdges().forEach(edge => edge.setZIndex(0));
+ graph.getNodes().forEach(node => {
+ node.setZIndex(node.getData()?.cycle ? 2 : 1);
+ });
+ };
+
/**
* Initialize workflow graph with nodes and edges from configuration
*/
@@ -470,24 +489,28 @@ export const useWorkflowGraph = ({
if (nodes.length > 0 || edges.length > 0) {
setTimeout(() => {
if (graphRef.current) {
- graphRef.current.getNodes().forEach(node => {
- if (!node.getData()?.cycle) node.toFront();
- });
- // Bring edges to front first, then child nodes above edges; parent nodes stay behind
- graphRef.current.getEdges().forEach(edge => {
- const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
- const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
- if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
- edge.toFront();
- }
- });
- graphRef.current.getNodes().forEach(node => {
- if (node.getData()?.cycle) node.toFront();
- });
+ if (isSafari) {
+ reorderCells(graphRef.current)
+ } else {
+ graphRef.current.getNodes().forEach(node => {
+ if (!node.getData()?.cycle) node.toFront();
+ });
+ // Bring edges to front first, then child nodes above edges; parent nodes stay behind
+ graphRef.current.getEdges().forEach(edge => {
+ const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
+ const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
+ if (sourceCell?.getData()?.cycle || targetCell?.getData()?.cycle) {
+ edge.toFront();
+ }
+ });
+ graphRef.current.getNodes().forEach(node => {
+ if (node.getData()?.cycle) node.toFront();
+ });
+ }
graphRef.current.enableHistory()
graphRef.current.cleanHistory()
}
- }, 200)
+ }, isSafari ? 0 : 200)
}
}
/**
@@ -551,12 +574,33 @@ export const useWorkflowGraph = ({
* @param node - Clicked node
*/
const nodeClick = ({ node }: { node: Node }) => {
+ // add-node type: dispatch port:click to open node selection popover
+ // Must handle before blankClick() to avoid blank:click closing the popover immediately
+ const nodeData = node.getData()
+ if (nodeData?.type === 'add-node') {
+ const bbox = node.getBBox();
+ const screenPos = graphRef.current!.localToClient(bbox.x + bbox.width, bbox.y + bbox.height / 2);
+ const tempDiv = document.createElement('div');
+ tempDiv.style.cssText = `position:fixed;left:${screenPos.x}px;top:${screenPos.y}px;width:1px;height:1px;z-index:9999;`;
+ document.body.appendChild(tempDiv);
+ window.dispatchEvent(new CustomEvent('port:click', {
+ detail: {
+ node,
+ port: 'right',
+ element: tempDiv,
+ rect: { left: screenPos.x, top: screenPos.y },
+ edgeInsertion: null,
+ },
+ }));
+ return;
+ }
+
blankClick()
setTimeout(() => {
- // Ignore add-node type node clicks
+ // Ignore add-node type node clicks
const nodeData = node.getData()
- if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
+ if (nodeData.type === 'break' || nodeData.type === 'cycle-start') {
setSelectedNode(null)
return;
}
@@ -644,7 +688,8 @@ export const useWorkflowGraph = ({
const cycle = node.getData()?.cycle;
if (cycle) {
const parentNode = graphRef.current!.getNodes().find(n => n.id === cycle);
- if (parentNode?.getData()?.isGroup) {
+ const parentType = parentNode?.getData()?.type;
+ if (parentNode && (parentType === 'loop' || parentType === 'iteration')) {
// Get parent node and child node bounding boxes
const parentBBox = parentNode.getBBox();
const childBBox = node.getBBox();
@@ -857,13 +902,35 @@ export const useWorkflowGraph = ({
/**
* Initialize X6 graph with configuration and event listeners
*/
- const init = () => {
+ const init = async () => {
if (!containerRef.current || !miniMapRef.current) return;
// Register React shapes
- nodeRegisterLibrary.forEach((item) => {
- register(item);
- });
+ // Safari: use x6-html-shape to avoid foreignObject rendering issues
+ if (isSafari) {
+ const { register: registerHtmlShape } = await import('x6-html-shape');
+ nodeRegisterLibrary.forEach(({ shape, width, height, component }) => {
+ registerHtmlShape({
+ shape,
+ width,
+ height,
+ render(node: Node, _graph: unknown, container: HTMLElement) {
+ const root = createRoot(container);
+ const doRender = () => {
+ root.render(createElement(component as any, { node, graph: node.model?.graph, data: node.getData() }));
+ };
+ doRender();
+ node.on('change:data', doRender);
+ return () => {
+ node.off('change:data', doRender);
+ root.unmount();
+ };
+ },
+ });
+ });
+ } else {
+ nodeRegisterLibrary.forEach((item) => registerReactShape(item));
+ }
const container = containerRef.current;
graphRef.current = new Graph({
@@ -1053,10 +1120,71 @@ export const useWorkflowGraph = ({
// Listen to node move event
graphRef.current.on('node:moved', nodeMoved);
+ if (isSafari) {
+ // When a parent (loop/iteration) node moves, keep child nodes in sync.
+ // Store each child's offset relative to the parent at drag start, then
+ // reapply it every frame to avoid cumulative delta errors.
+ const dragOffsets = new Map();
+
+ graphRef.current.on('node:moving', ({ node }: { node: Node }) => {
+ const data = node.getData();
+ if (data?.type !== 'loop' && data?.type !== 'iteration') return;
+ const pos = node.getPosition();
+ const PORT_RADIUS = 6;
+
+ // Update parent componentContainer directly
+ const parentView = graphRef.current?.findViewByCell(node) as any;
+ if (parentView?.componentContainer) {
+ parentView.componentContainer.style.transform =
+ `translate(${pos.x + PORT_RADIUS}px, ${pos.y}px)`;
+ }
+
+ const children = graphRef.current?.getNodes().filter(child => {
+ const cycle = child.getData()?.cycle;
+ return cycle === data.id || cycle === node.id;
+ }) ?? [];
+
+ // First event for this drag: record offsets
+ if (!dragOffsets.has(node.id)) {
+ children.forEach(child => {
+ const cp = child.getPosition();
+ dragOffsets.set(child.id, { dx: cp.x - pos.x, dy: cp.y - pos.y });
+ });
+ }
+
+ // Apply stored offsets to keep children in place relative to parent
+ children.forEach(child => {
+ const off = dragOffsets.get(child.id);
+ if (!off) return;
+ const nx = pos.x + off.dx;
+ const ny = pos.y + off.dy;
+ child.setPosition(nx, ny);
+ const childView = graphRef.current?.findViewByCell(child) as any;
+ if (childView?.componentContainer) {
+ childView.componentContainer.style.transform =
+ `translate(${nx + PORT_RADIUS}px, ${ny}px)`;
+ }
+ });
+ });
+
+ graphRef.current.on('node:moved', ({ node }: { node: Node }) => {
+ // Clear offsets for this parent and all its children
+ const data = node.getData();
+ graphRef.current?.getNodes().forEach(child => {
+ const cycle = child.getData()?.cycle;
+ if (cycle === data?.id || cycle === node.id) dragOffsets.delete(child.id);
+ });
+ dragOffsets.delete(node.id);
+ nodeMoved({ node });
+ });
+ }
+
graphRef.current.on('node:removed', blankClick)
- // When edge connected, bring connected nodes' ports to front
+ // When edge connected, reorder all cells to maintain correct layer order
graphRef.current.on('edge:connected', ({ isNew, edge }) => {
- if (isNew) {
+ if (isSafari && isNew && graphRef.current) {
+ reorderCells(graphRef.current);
+ } else if (!isSafari && isNew) {
const sourceCellId = edge.getSourceCellId()
const targetCellId = edge.getTargetCellId()
const sourceCell = graphRef.current?.getCellById(sourceCellId);
@@ -1170,7 +1298,7 @@ export const useWorkflowGraph = ({
* Creates new node at drop position
* @param event - React drag event
*/
- const onDrop = (event: React.DragEvent) => {
+ const onDrop = (event: DragEvent) => {
if (!graphRef.current) return;
event.preventDefault();
const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 4a1a0b34..c651eeed 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -44,6 +44,9 @@ export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
+ 'x6-html-shape': resolve(__dirname, 'src/vendor/x6-html-shape/index.js'),
+ 'x6-html-shape/dist/react': resolve(__dirname, 'src/vendor/x6-html-shape/react.js'),
+ 'x6-html-shape/dist/utils.js': resolve(__dirname, 'src/vendor/x6-html-shape/utils.js'),
},
},
base: './', // 使用相对路径,确保资源能正确加载