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: './', // 使用相对路径,确保资源能正确加载