Compare commits
1 Commits
fix/wxy-03
...
feature/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8476f3b7a8 |
@@ -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"
|
||||
},
|
||||
|
||||
176
web/src/vendor/x6-html-shape/index.js
vendored
Normal file
176
web/src/vendor/x6-html-shape/index.js
vendored
Normal file
@@ -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 };
|
||||
1
web/src/vendor/x6-html-shape/react.js
vendored
Normal file
1
web/src/vendor/x6-html-shape/react.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "x6-html-shape/dist/react.js";
|
||||
98
web/src/vendor/x6-html-shape/utils.js
vendored
Normal file
98
web/src/vendor/x6-html-shape/utils.js
vendored
Normal file
@@ -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
|
||||
};
|
||||
@@ -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 = (
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto', minWidth: `${nodeWidth}px'` }}>
|
||||
{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 (
|
||||
<div key={category.category}>
|
||||
{categoryIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
|
||||
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
|
||||
{t(`workflow.${category.category}`)}
|
||||
</div>
|
||||
{filteredNodes.map((nodeType) => (
|
||||
<div
|
||||
key={nodeType.type}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onClick={() => handleNodeSelect(nodeType)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f0f8ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white';
|
||||
}}
|
||||
>
|
||||
<div className={`rb:size-4 rb:bg-cover ${nodeType.icon}`} />
|
||||
<span style={{ fontSize: '14px' }}>{t(`workflow.${nodeType.type}`)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="bottomLeft"
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={4}
|
||||
className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-[#FCFCFD] rb:text-[#475467]': data.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-[#FCFCFD] rb:text-[#374151]': !data.isSelected
|
||||
})}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
gap={4}
|
||||
className={clsx('rb:text-[#212332] rb:font-medium rb:text-[12px] rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-lg rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:border-[#FCFCFD] rb:flex rb:items-center rb:justify-center', {
|
||||
'rb:border-orange-500 rb:border-[3px] rb:bg-[#FCFCFD] rb:text-[#475467]': data.isSelected,
|
||||
'rb:border-[#d1d5db] rb:bg-[#FCFCFD] rb:text-[#374151]': !data.isSelected
|
||||
})}
|
||||
>
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
|
||||
{data.label}
|
||||
</Flex>
|
||||
</Popover>
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/node_plus.png')]"></div>
|
||||
{data.label}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddNode;
|
||||
export default AddNode;
|
||||
|
||||
@@ -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<PortClickHandlerProps> = ({ graph }) => {
|
||||
const { t } = useTranslation();
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
const [popoverPosition, setPopoverPosition] = useState({ x: 0, y: 0 });
|
||||
const [sourceNode, setSourceNode] = useState<any>(null);
|
||||
const [sourcePort, setSourcePort] = useState<string>('');
|
||||
const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
|
||||
@@ -24,12 +54,11 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ 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<PortClickHandlerProps> = ({ 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<PortClickHandlerProps> = ({ 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<PortClickHandlerProps> = ({ graph }) => {
|
||||
|
||||
if (!tempElement) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<Popover
|
||||
content={content}
|
||||
open={popoverVisible}
|
||||
onOpenChange={(visible) => {
|
||||
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}
|
||||
>
|
||||
<div />
|
||||
</Popover>
|
||||
<div style={{ width: '1px', height: '1px' }} />
|
||||
</Popover>,
|
||||
tempElement
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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<HTMLDivElement>;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
/** Reference to the minimap container element */
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
miniMapRef: RefObject<HTMLDivElement>;
|
||||
/** 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<React.SetStateAction<WorkflowConfig | null>>;
|
||||
setConfig: Dispatch<SetStateAction<WorkflowConfig | null>>;
|
||||
/** Reference to the X6 graph instance */
|
||||
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||
graphRef: MutableRefObject<Graph | undefined>;
|
||||
/** Currently selected node */
|
||||
selectedNode: Node | null;
|
||||
/** Function to update selected node */
|
||||
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
|
||||
setSelectedNode: Dispatch<SetStateAction<Node | null>>;
|
||||
/** Current zoom level of the graph */
|
||||
zoomLevel: number;
|
||||
/** Function to update zoom level */
|
||||
setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
|
||||
setZoomLevel: Dispatch<SetStateAction<number>>;
|
||||
/** Whether hand/pan mode is enabled */
|
||||
isHandMode: boolean;
|
||||
/** Function to toggle hand mode */
|
||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsHandMode: Dispatch<SetStateAction<boolean>>;
|
||||
/** 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<React.SetStateAction<ChatVariable[]>>;
|
||||
setChatVariables: Dispatch<SetStateAction<ChatVariable[]>>;
|
||||
|
||||
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<string, { dx: number; dy: number }>();
|
||||
|
||||
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'));
|
||||
|
||||
@@ -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: './', // 使用相对路径,确保资源能正确加载
|
||||
|
||||
Reference in New Issue
Block a user