fix(web): workflow redo/undo
This commit is contained in:
@@ -44,7 +44,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
|||||||
if (cycleId) {
|
if (cycleId) {
|
||||||
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
||||||
if (parentNode) {
|
if (parentNode) {
|
||||||
parentNode.addChild(newNode);
|
parentNode.addChild(newNode, { silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,57 +77,40 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
graph.stopBatch('add-node');
|
|
||||||
|
|
||||||
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
|
// Automatically adjust loop node size
|
||||||
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
||||||
if (loopNode) {
|
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);
|
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
|
||||||
childNodes.forEach((childNode: any) => {
|
if (childNodes.length > 0) {
|
||||||
childNode.on('change:position', adjustLoopSize);
|
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 });
|
||||||
|
loopNode.getPorts().forEach(port => {
|
||||||
|
if (port.group === 'right' && port.args) {
|
||||||
|
loopNode.portProp(port.id!, 'args/x', newWidth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
graph.stopBatch('add-node');
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,71 +43,52 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle node selection from popover menu and create new node with edge connection
|
|
||||||
const handleNodeSelect = (selectedNodeType: any) => {
|
const handleNodeSelect = (selectedNodeType: any) => {
|
||||||
if (!sourceNode || !graph) return;
|
if (!sourceNode || !graph) return;
|
||||||
graph.startBatch('add-node');
|
|
||||||
|
|
||||||
const sourceNodeData = sourceNode.getData();
|
const sourceNodeData = sourceNode.getData();
|
||||||
const sourceNodeType = sourceNodeData?.type;
|
const sourceNodeType = sourceNodeData?.type;
|
||||||
|
const isCycleSubNode = !!sourceNodeData.cycle;
|
||||||
|
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
|
||||||
|
const newNodeType = selectedNodeType.type;
|
||||||
|
|
||||||
// If it's a cycle-start node, handle the add-node placeholder
|
// Save add-node placeholder position before disabling history
|
||||||
let addNodePosition = null;
|
let addNodePosition = null;
|
||||||
const isCycleSubNode = sourceNodeData.cycle
|
|
||||||
if (isCycleSubNode && sourceNodeType === 'cycle-start') {
|
if (isCycleSubNode && sourceNodeType === 'cycle-start') {
|
||||||
const cycleId = sourceNodeData.cycle;
|
const cycleId = sourceNodeData.cycle;
|
||||||
const addNodes = graph.getNodes().filter((n: any) =>
|
const addNodes = graph.getNodes().filter((n: any) =>
|
||||||
n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId
|
n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId
|
||||||
);
|
);
|
||||||
|
if (addNodes.length > 0) addNodePosition = addNodes[0].getBBox();
|
||||||
if (addNodes.length > 0) {
|
|
||||||
const addNode = addNodes[0];
|
|
||||||
addNodePosition = addNode.getBBox();
|
|
||||||
addNode.remove();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new node position to avoid overlapping
|
// Calculate position
|
||||||
const sourceBBox = sourceNode.getBBox();
|
const sourceBBox = sourceNode.getBBox();
|
||||||
const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120;
|
const nw = graphNodeLibrary[newNodeType]?.width || 120;
|
||||||
const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88;
|
const nh = graphNodeLibrary[newNodeType]?.height || 88;
|
||||||
const horizontalSpacing = isCycleSubNode ? 48 : 80;
|
const hSpacing = isCycleSubNode ? 48 : 80;
|
||||||
const verticalSpacing = 10;
|
const vSpacing = 10;
|
||||||
|
|
||||||
// Get source port group information
|
|
||||||
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
|
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
|
||||||
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
|
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
|
||||||
|
|
||||||
// Calculate new node position
|
let newX: number, newY: number;
|
||||||
let newX, newY;
|
|
||||||
if (edgeInsertion) {
|
if (edgeInsertion) {
|
||||||
// Edge insertion: place new node on the same row as target, between source and target
|
|
||||||
const targetBBox = edgeInsertion.targetCell.getBBox();
|
const targetBBox = edgeInsertion.targetCell.getBBox();
|
||||||
const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width);
|
const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width);
|
||||||
const requiredSpace = nodeWidth + horizontalSpacing * 4;
|
const requiredSpace = nw + hSpacing * 4;
|
||||||
|
newX = sourceBBox.x + sourceBBox.width + hSpacing;
|
||||||
// New node x: right after source + spacing
|
newY = targetBBox.y + (targetBBox.height - nh) / 2;
|
||||||
newX = sourceBBox.x + sourceBBox.width + horizontalSpacing;
|
|
||||||
// Same row as target node
|
|
||||||
newY = targetBBox.y + (targetBBox.height - nodeHeight) / 2;
|
|
||||||
|
|
||||||
// If not enough space, shift target and all downstream nodes to the right
|
|
||||||
if (gap < requiredSpace) {
|
if (gap < requiredSpace) {
|
||||||
const shiftX = requiredSpace - gap;
|
const shiftX = requiredSpace - gap;
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
const shiftDownstream = (cell: any) => {
|
const shiftDownstream = (cell: any) => {
|
||||||
const cellId = cell.id;
|
if (visited.has(cell.id)) return;
|
||||||
if (visited.has(cellId)) return;
|
visited.add(cell.id);
|
||||||
visited.add(cellId);
|
|
||||||
const pos = cell.getPosition();
|
const pos = cell.getPosition();
|
||||||
cell.setPosition(pos.x + shiftX, pos.y);
|
cell.setPosition(pos.x + shiftX, pos.y);
|
||||||
// Recursively shift nodes connected from right ports
|
|
||||||
graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
|
graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
|
||||||
const tId = e.getTargetCellId();
|
const tCell = graph.getCellById(e.getTargetCellId());
|
||||||
if (tId && !visited.has(tId)) {
|
if (tCell?.isNode()) shiftDownstream(tCell);
|
||||||
const tCell = graph.getCellById(tId);
|
|
||||||
if (tCell?.isNode()) shiftDownstream(tCell);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
shiftDownstream(edgeInsertion.targetCell);
|
shiftDownstream(edgeInsertion.targetCell);
|
||||||
@@ -115,209 +96,170 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
|||||||
} else if (addNodePosition) {
|
} else if (addNodePosition) {
|
||||||
newX = addNodePosition.x;
|
newX = addNodePosition.x;
|
||||||
newY = addNodePosition.y;
|
newY = addNodePosition.y;
|
||||||
|
} else if (sourcePortGroup === 'left') {
|
||||||
|
newX = sourceBBox.x - nw * 2 - hSpacing;
|
||||||
|
newY = sourceBBox.y;
|
||||||
} else {
|
} else {
|
||||||
// Determine node placement direction based on port position
|
newX = sourceBBox.x + sourceBBox.width + hSpacing;
|
||||||
if (sourcePortGroup === 'left') {
|
newY = sourceBBox.y;
|
||||||
// Left port: add node to the left
|
const connectedNodes = new Set<string>();
|
||||||
newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing;
|
graph.getConnectedEdges(sourceNode).forEach((e: any) => {
|
||||||
newY = sourceBBox.y;
|
[e.getSourceCellId(), e.getTargetCellId()].forEach((cid: string) => {
|
||||||
} else {
|
if (cid !== sourceNode.id) connectedNodes.add(cid);
|
||||||
// Right port: add node to the right
|
|
||||||
newX = sourceBBox.x + sourceBBox.width + horizontalSpacing;
|
|
||||||
newY = sourceBBox.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if position overlaps with existing nodes (only consider connected nodes)
|
|
||||||
const checkOverlap = (x: number, y: number) => {
|
|
||||||
// Get nodes connected to the source node
|
|
||||||
const connectedNodes = new Set();
|
|
||||||
graph.getConnectedEdges(sourceNode).forEach((edge: any) => {
|
|
||||||
const sourceId = edge.getSourceCellId();
|
|
||||||
const targetId = edge.getTargetCellId();
|
|
||||||
if (sourceId !== sourceNode.id) connectedNodes.add(sourceId);
|
|
||||||
if (targetId !== sourceNode.id) connectedNodes.add(targetId);
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
return graph.getNodes().some((node: any) => {
|
const checkOverlap = (x: number, y: number) =>
|
||||||
if (node.id === sourceNode.id) return false;
|
graph.getNodes().some((n: any) => {
|
||||||
if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes
|
if (n.id === sourceNode.id || !connectedNodes.has(n.id)) return false;
|
||||||
const bbox = node.getBBox();
|
const b = n.getBBox();
|
||||||
return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width ||
|
return !(x + nw < b.x || x > b.x + b.width || y + nh < b.y || y > b.y + b.height);
|
||||||
y + nodeHeight < bbox.y || y > bbox.y + bbox.height);
|
|
||||||
});
|
});
|
||||||
};
|
while (checkOverlap(newX, newY)) newY += nh + vSpacing;
|
||||||
|
|
||||||
// If position is occupied, search downward for empty space
|
|
||||||
while (checkOverlap(newX, newY)) {
|
|
||||||
newY += nodeHeight + verticalSpacing;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new node
|
// Disable history for all graph mutations
|
||||||
const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
graph.disableHistory();
|
||||||
|
|
||||||
|
// Remove add-node placeholder
|
||||||
|
if (isCycleSubNode && sourceNodeType === 'cycle-start') {
|
||||||
|
const cycleId = sourceNodeData.cycle;
|
||||||
|
graph.getNodes()
|
||||||
|
.filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId)
|
||||||
|
.forEach((n: any) => n.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `${newNodeType.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
const newNode = graph.addNode({
|
const newNode = graph.addNode({
|
||||||
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
|
...(graphNodeLibrary[newNodeType] || graphNodeLibrary.default),
|
||||||
x: newX,
|
x: newX,
|
||||||
y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0),
|
y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0),
|
||||||
id,
|
id,
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
type: selectedNodeType.type,
|
type: newNodeType,
|
||||||
icon: selectedNodeType.icon,
|
icon: selectedNodeType.icon,
|
||||||
name: t(`workflow.${selectedNodeType.type}`),
|
name: t(`workflow.${newNodeType}`),
|
||||||
cycle: sourceNodeData.cycle, // Inherit cycle from source node
|
cycle: sourceNodeData.cycle,
|
||||||
config: selectedNodeType.config || {}
|
config: selectedNodeType.config || {}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add new node as child of parent node
|
|
||||||
if (sourceNodeData.cycle) {
|
if (sourceNodeData.cycle) {
|
||||||
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
|
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
|
||||||
if (parentNode) {
|
if (parentNode) parentNode.addChild(newNode, { silent: true });
|
||||||
parentNode.addChild(newNode);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edge insertion: remove old edge immediately before creating new edges
|
|
||||||
if (edgeInsertion) {
|
if (edgeInsertion) {
|
||||||
const { edge: oldEdge } = edgeInsertion;
|
const { edge: oldEdge } = edgeInsertion;
|
||||||
if (oldEdge.id && graph.getCellById(oldEdge.id)) {
|
if (oldEdge.id && graph.getCellById(oldEdge.id)) graph.removeCell(oldEdge.id);
|
||||||
graph.removeCell(oldEdge.id);
|
else graph.removeEdge(oldEdge);
|
||||||
} else {
|
|
||||||
graph.removeEdge(oldEdge);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create edge connection
|
const newPorts = newNode.getPorts();
|
||||||
setTimeout(() => {
|
const addedCells: any[] = [newNode];
|
||||||
const newPorts = newNode.getPorts();
|
|
||||||
|
|
||||||
const addedEdges: any[] = [];
|
if (edgeInsertion) {
|
||||||
if (edgeInsertion) {
|
const { targetCell, targetPort: origTargetPort } = edgeInsertion;
|
||||||
// Edge insertion: create source→new and new→target edges
|
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
|
||||||
const { targetCell, targetPort: origTargetPort } = edgeInsertion;
|
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
|
||||||
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
|
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
|
||||||
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
|
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
|
||||||
addedEdges.push(graph.addEdge({
|
setEdgeInsertion(null);
|
||||||
source: { cell: sourceNode.id, port: sourcePort },
|
} else if (sourcePortGroup === 'left') {
|
||||||
target: { cell: newNode.id, port: newLeftPort },
|
const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
|
||||||
...edgeAttrs
|
addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs }));
|
||||||
}));
|
} else {
|
||||||
addedEdges.push(graph.addEdge({
|
const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
|
||||||
source: { cell: newNode.id, port: newRightPort },
|
addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs }));
|
||||||
target: { cell: targetCell.id, port: origTargetPort },
|
}
|
||||||
...edgeAttrs
|
|
||||||
}));
|
|
||||||
setEdgeInsertion(null);
|
|
||||||
} else if (sourcePortGroup === 'left') {
|
|
||||||
// Connect from left port to new node's right side
|
|
||||||
const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right';
|
|
||||||
addedEdges.push(graph.addEdge({
|
|
||||||
source: { cell: newNode.id, port: targetPort },
|
|
||||||
target: { cell: sourceNode.id, port: sourcePort },
|
|
||||||
...edgeAttrs
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Connect from right port to new node's left side
|
|
||||||
const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left';
|
|
||||||
addedEdges.push(graph.addEdge({
|
|
||||||
source: { cell: sourceNode.id, port: sourcePort },
|
|
||||||
target: { cell: newNode.id, port: targetPort },
|
|
||||||
...edgeAttrs
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust loop node size when child node is added via port within loop node
|
// If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type
|
||||||
const cycleId = sourceNodeData.cycle;
|
if (isCycleContainer(newNodeType)) {
|
||||||
if (cycleId) {
|
const parentBBox = newNode.getBBox();
|
||||||
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const cycleStartNode = graph.addNode({
|
||||||
|
...graphNodeLibrary.cycleStart,
|
||||||
|
x: parentBBox.x + 24,
|
||||||
|
y: parentBBox.y + 70,
|
||||||
|
id: cycleStartId,
|
||||||
|
data: { id: cycleStartId, type: 'cycle-start', parentId: id, isDefault: true, cycle: id },
|
||||||
|
});
|
||||||
|
const addNodePlaceholder = graph.addNode({
|
||||||
|
...graphNodeLibrary.addStart,
|
||||||
|
x: parentBBox.x + 24 + 84,
|
||||||
|
y: parentBBox.y + 70 + 4,
|
||||||
|
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id },
|
||||||
|
});
|
||||||
|
newNode.addChild(cycleStartNode, { silent: true });
|
||||||
|
newNode.addChild(addNodePlaceholder, { silent: true });
|
||||||
|
const innerEdge = graph.addEdge({
|
||||||
|
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' },
|
||||||
|
target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' },
|
||||||
|
...edgeAttrs,
|
||||||
|
});
|
||||||
|
addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge);
|
||||||
|
}
|
||||||
|
|
||||||
if (parentNode) {
|
// Adjust parent size if adding inside a cycle container
|
||||||
const adjustLoopSize = () => {
|
const cycleId = sourceNodeData.cycle;
|
||||||
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
|
if (cycleId) {
|
||||||
if (childNodes.length > 0) {
|
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
|
||||||
const bounds = childNodes.reduce((acc: any, child: any) => {
|
if (parentNode) {
|
||||||
const bbox = child.getBBox();
|
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
|
||||||
return {
|
if (childNodes.length > 0) {
|
||||||
minX: Math.min(acc.minX, bbox.x),
|
const bounds = childNodes.reduce((acc: any, child: any) => {
|
||||||
minY: Math.min(acc.minY, bbox.y),
|
const b = child.getBBox();
|
||||||
maxX: Math.max(acc.maxX, bbox.x + bbox.width),
|
return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) };
|
||||||
maxY: Math.max(acc.maxY, bbox.y + bbox.height)
|
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
||||||
};
|
const padding = 50;
|
||||||
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity });
|
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
|
||||||
|
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
|
||||||
const padding = 50;
|
parentNode.prop('size', { width: newWidth, height: newHeight });
|
||||||
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2);
|
parentNode.getPorts().forEach((port: any) => {
|
||||||
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2);
|
if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth);
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
|
// toFront
|
||||||
const newNodeType = selectedNodeType.type;
|
const bringCycleChildrenToFront = (cycleContainerId: string) => {
|
||||||
|
graph.getEdges().forEach((e: any) => {
|
||||||
|
const src = graph.getCellById(e.getSourceCellId());
|
||||||
|
const tgt = graph.getCellById(e.getTargetCellId());
|
||||||
|
if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront();
|
||||||
|
});
|
||||||
|
graph.getNodes().forEach((n: any) => { if (n.getData()?.cycle === cycleContainerId) n.toFront(); });
|
||||||
|
};
|
||||||
|
|
||||||
// Helper: bring all child nodes and their edges of a cycle container to front
|
if (isCycleContainer(sourceNodeType)) {
|
||||||
const bringCycleChildrenToFront = (cycleContainerId: string) => {
|
newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(sourceNodeData.id);
|
||||||
|
if (isCycleContainer(newNodeType)) bringCycleChildrenToFront(id);
|
||||||
|
} else if (isCycleContainer(newNodeType)) {
|
||||||
|
newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(id);
|
||||||
|
} else {
|
||||||
|
addedCells.forEach(c => { if (c.isNode?.()) c.toFront(); });
|
||||||
|
}
|
||||||
|
|
||||||
graph.getEdges().forEach((e: any) => {
|
// Re-enable history and manually push one batch frame for all added cells
|
||||||
const src = graph.getCellById(e.getSourceCellId());
|
graph.enableHistory();
|
||||||
const tgt = graph.getCellById(e.getTargetCellId());
|
const history = graph.getPlugin('history') as any;
|
||||||
if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront();
|
if (history) {
|
||||||
});
|
const batchFrame = addedCells.map((cell: any) => ({
|
||||||
graph.getNodes().forEach((n: any) => {
|
batch: true,
|
||||||
if (n.getData()?.cycle === cycleContainerId) n.toFront();
|
event: 'cell:added',
|
||||||
});
|
data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() },
|
||||||
};
|
options: {},
|
||||||
|
}));
|
||||||
|
history.undoStack.push(batchFrame);
|
||||||
|
history.redoStack = [];
|
||||||
|
graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-node' } });
|
||||||
|
}
|
||||||
|
|
||||||
if (isCycleContainer(sourceNodeType)) {
|
|
||||||
console.log('isCycleContainer(sourceNodeType)')
|
|
||||||
// Case 4: source is a loop/iteration node — bring new node to front, then its children
|
|
||||||
newNode.toFront();
|
|
||||||
sourceNode.toFront();
|
|
||||||
bringCycleChildrenToFront(sourceNodeData.id);
|
|
||||||
} else if (isCycleContainer(newNodeType)) {
|
|
||||||
console.log('isCycleContainer(newNodeType)')
|
|
||||||
// Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children
|
|
||||||
newNode.toFront();
|
|
||||||
sourceNode.toFront()
|
|
||||||
bringCycleChildrenToFront(id);
|
|
||||||
} else {
|
|
||||||
// Case 2: normal node → normal node
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
graph.stopBatch('add-node');
|
|
||||||
}, 50);
|
|
||||||
|
|
||||||
// Clean up temporary element
|
|
||||||
if (tempElement) {
|
if (tempElement) {
|
||||||
document.body.removeChild(tempElement);
|
document.body.removeChild(tempElement);
|
||||||
setTempElement(null);
|
setTempElement(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setPopoverVisible(false);
|
setPopoverVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2026-02-03 15:17:48
|
* @Date: 2026-02-03 15:17:48
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2026-04-27 16:30:30
|
* @Last Modified time: 2026-04-28 13:49:11
|
||||||
*/
|
*/
|
||||||
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
|
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
|
||||||
import { register } from '@antv/x6-react-shape';
|
import { register } from '@antv/x6-react-shape';
|
||||||
@@ -16,7 +16,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application';
|
|||||||
import { useUser } from '@/store/user';
|
import { useUser } from '@/store/user';
|
||||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||||
import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant';
|
import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant';
|
||||||
import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types';
|
import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from '../types';
|
||||||
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
|
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
|
||||||
import { useWorkflowStore } from '@/store/workflow';
|
import { useWorkflowStore } from '@/store/workflow';
|
||||||
|
|
||||||
@@ -85,6 +85,10 @@ export interface UseWorkflowGraphReturn {
|
|||||||
/** Get start node output variable list (user-defined + system variables) */
|
/** Get start node output variable list (user-defined + system variables) */
|
||||||
getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>;
|
getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>;
|
||||||
nodeClick: ({ node }: { node: Node }) => void;
|
nodeClick: ({ node }: { node: Node }) => void;
|
||||||
|
/** All recorded history operations */
|
||||||
|
historyRecords: HistoryRecord[];
|
||||||
|
/** Clear history records */
|
||||||
|
clearHistoryRecords: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,7 +122,12 @@ export const useWorkflowGraph = ({
|
|||||||
const featuresRef = useRef<FeaturesConfigForm | undefined>(undefined)
|
const featuresRef = useRef<FeaturesConfigForm | undefined>(undefined)
|
||||||
const [canUndo, setCanUndo] = useState(false)
|
const [canUndo, setCanUndo] = useState(false)
|
||||||
const [canRedo, setCanRedo] = useState(false)
|
const [canRedo, setCanRedo] = useState(false)
|
||||||
|
const [historyRecords, setHistoryRecords] = useState<HistoryRecord[]>([])
|
||||||
|
const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null)
|
||||||
|
const undoRef = useRef<() => void>(() => {})
|
||||||
|
const redoRef = useRef<() => void>(() => {})
|
||||||
|
const syncChildRelationshipsRef = useRef<() => void>(() => {})
|
||||||
|
const isSyncingRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!graphRef.current) return
|
if (!graphRef.current) return
|
||||||
graphRef.current.getNodes().forEach(node => {
|
graphRef.current.getNodes().forEach(node => {
|
||||||
@@ -342,7 +351,7 @@ export const useWorkflowGraph = ({
|
|||||||
if (parentNode) {
|
if (parentNode) {
|
||||||
const addedChild = graphRef.current?.addNode(childNode)
|
const addedChild = graphRef.current?.addNode(childNode)
|
||||||
if (addedChild) {
|
if (addedChild) {
|
||||||
parentNode.addChild(addedChild)
|
parentNode.addChild(addedChild, { silent: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -373,8 +382,6 @@ export const useWorkflowGraph = ({
|
|||||||
const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2)
|
const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2)
|
||||||
const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight)
|
const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight)
|
||||||
|
|
||||||
console.log('newWidth', newHeight, newWidth)
|
|
||||||
|
|
||||||
parentNode.prop('size', { width: newWidth, height: newHeight })
|
parentNode.prop('size', { width: newWidth, height: newHeight })
|
||||||
|
|
||||||
// Update x position of right group ports
|
// Update x position of right group ports
|
||||||
@@ -523,30 +530,28 @@ export const useWorkflowGraph = ({
|
|||||||
const syncChildRelationships = () => {
|
const syncChildRelationships = () => {
|
||||||
if (!graphRef.current) return
|
if (!graphRef.current) return
|
||||||
const graph = graphRef.current
|
const graph = graphRef.current
|
||||||
// Re-establish parent-child relationships based on cycle data
|
graph.disableHistory()
|
||||||
graph.getNodes().forEach(node => {
|
graph.getNodes().forEach(node => {
|
||||||
const cycleId = node.getData()?.cycle
|
const cycleId = node.getData()?.cycle
|
||||||
if (!cycleId) return
|
if (!cycleId) return
|
||||||
const parentNode = graph.getCellById(cycleId) as Node | null
|
const parentNode = graph.getCellById(cycleId) as Node | null
|
||||||
if (!parentNode) return
|
if (!parentNode) return
|
||||||
if (!parentNode.getChildren()?.some(c => c.id === node.id)) {
|
if (!parentNode.getChildren()?.some(c => c.id === node.id)) {
|
||||||
parentNode.addChild(node)
|
parentNode.addChild(node, { silent: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Remove stale parent-child links (parent exists but child's cycle no longer points to it)
|
|
||||||
graph.getNodes().forEach(node => {
|
graph.getNodes().forEach(node => {
|
||||||
const children = node.getChildren()
|
const children = node.getChildren()
|
||||||
if (!children?.length) return
|
if (!children?.length) return
|
||||||
children.forEach(child => {
|
children.forEach(child => {
|
||||||
|
if (!child.isNode()) return
|
||||||
const childCycleId = (child as Node).getData?.()?.cycle
|
const childCycleId = (child as Node).getData?.()?.cycle
|
||||||
if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) {
|
if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) {
|
||||||
node.removeChild(child)
|
node.removeChild(child, { silent: true })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// Recalculate group node size based on current children
|
|
||||||
resizeGroupNodes(graph)
|
resizeGroupNodes(graph)
|
||||||
// Bring child edges and nodes to front
|
|
||||||
graph.getEdges().forEach(edge => {
|
graph.getEdges().forEach(edge => {
|
||||||
const src = graph.getCellById(edge.getSourceCellId())
|
const src = graph.getCellById(edge.getSourceCellId())
|
||||||
const tgt = graph.getCellById(edge.getTargetCellId())
|
const tgt = graph.getCellById(edge.getTargetCellId())
|
||||||
@@ -557,7 +562,9 @@ export const useWorkflowGraph = ({
|
|||||||
graph.getNodes().forEach(node => {
|
graph.getNodes().forEach(node => {
|
||||||
if (node.getData()?.cycle) node.toFront()
|
if (node.getData()?.cycle) node.toFront()
|
||||||
})
|
})
|
||||||
|
graph.enableHistory()
|
||||||
}
|
}
|
||||||
|
syncChildRelationshipsRef.current = syncChildRelationships
|
||||||
/**
|
/**
|
||||||
* Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard)
|
* Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard)
|
||||||
*/
|
*/
|
||||||
@@ -593,19 +600,44 @@ export const useWorkflowGraph = ({
|
|||||||
new History({
|
new History({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
beforeAddCommand(_event, args: any) {
|
beforeAddCommand(_event, args: any) {
|
||||||
const event = args?.key ? `cell:change:${args.key}` : _event;
|
const key = args?.key
|
||||||
const allowed = ['cell:added', 'cell:removed', 'cell:change:position', 'cell:change:source', 'cell:change:target'];
|
if (key === 'attrs' || key === 'tools') return false
|
||||||
if (!allowed.includes(event)) return false;
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
graphRef.current.on('history:change', () => {
|
const MERGE_INTERVAL = 1000
|
||||||
|
graphRef.current.on('history:change', ({ cmds, options }: { cmds: any[]; options: any }) => {
|
||||||
setCanUndo(graphRef.current?.canUndo() ?? false)
|
setCanUndo(graphRef.current?.canUndo() ?? false)
|
||||||
setCanRedo(graphRef.current?.canRedo() ?? false)
|
setCanRedo(graphRef.current?.canRedo() ?? false)
|
||||||
|
console.log('history:change', cmds, options)
|
||||||
|
const batchName: string | undefined = options?.name
|
||||||
|
const actionType = batchName === 'undo' ? 'undo' : batchName === 'redo' ? 'redo' : batchName ? 'batch' : 'change'
|
||||||
|
const cellIds = [...new Set(cmds?.map((cmd: any) => cmd.data?.id).filter(Boolean))]
|
||||||
|
const now = Date.now()
|
||||||
|
const last = lastHistoryRef.current
|
||||||
|
const canMerge =
|
||||||
|
actionType === 'change' &&
|
||||||
|
last?.type === 'change' &&
|
||||||
|
now - last.timestamp < MERGE_INTERVAL &&
|
||||||
|
cellIds.length > 0 &&
|
||||||
|
cellIds.length === last.cellIds.length &&
|
||||||
|
cellIds.every((id, i) => id === last.cellIds[i])
|
||||||
|
if (canMerge) {
|
||||||
|
lastHistoryRef.current!.timestamp = now
|
||||||
|
setHistoryRecords(prev => {
|
||||||
|
const next = [...prev]
|
||||||
|
next[next.length - 1] = { ...next[next.length - 1], timestamp: now }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const record: HistoryRecord = { type: actionType, timestamp: now, batchName, cellIds }
|
||||||
|
lastHistoryRef.current = { cellIds, timestamp: now, type: actionType }
|
||||||
|
setHistoryRecords(prev => [...prev, record])
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
graphRef.current.on('history:undo', syncChildRelationships)
|
graphRef.current.on('history:undo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() })
|
||||||
graphRef.current.on('history:redo', syncChildRelationships)
|
graphRef.current.on('history:redo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() })
|
||||||
};
|
};
|
||||||
// 显示/隐藏连接桩
|
// 显示/隐藏连接桩
|
||||||
// const showPorts = (show: boolean) => {
|
// const showPorts = (show: boolean) => {
|
||||||
@@ -638,13 +670,13 @@ export const useWorkflowGraph = ({
|
|||||||
vo.setData({
|
vo.setData({
|
||||||
...data,
|
...data,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
});
|
}, { silent: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
node.setData({
|
node.setData({
|
||||||
...nodeData,
|
...nodeData,
|
||||||
isSelected: true,
|
isSelected: true,
|
||||||
});
|
}, { silent: true });
|
||||||
clearEdgeSelect()
|
clearEdgeSelect()
|
||||||
if (nodeData.type !== 'notes') {
|
if (nodeData.type !== 'notes') {
|
||||||
setSelectedNode(node);
|
setSelectedNode(node);
|
||||||
@@ -658,7 +690,7 @@ export const useWorkflowGraph = ({
|
|||||||
const edgeClick = ({ edge }: { edge: Edge }) => {
|
const edgeClick = ({ edge }: { edge: Edge }) => {
|
||||||
clearEdgeSelect();
|
clearEdgeSelect();
|
||||||
edge.setAttrByPath('line/stroke', edge_selected_color);
|
edge.setAttrByPath('line/stroke', edge_selected_color);
|
||||||
edge.setData({ ...edge.getData(), isSelected: true });
|
edge.setData({ ...edge.getData(), isSelected: true }, { silent: true });
|
||||||
clearNodeSelect();
|
clearNodeSelect();
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
@@ -673,7 +705,7 @@ export const useWorkflowGraph = ({
|
|||||||
node.setData({
|
node.setData({
|
||||||
...data,
|
...data,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
});
|
}, { silent: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
@@ -683,7 +715,7 @@ export const useWorkflowGraph = ({
|
|||||||
*/
|
*/
|
||||||
const clearEdgeSelect = () => {
|
const clearEdgeSelect = () => {
|
||||||
graphRef.current?.getEdges().forEach(e => {
|
graphRef.current?.getEdges().forEach(e => {
|
||||||
e.setData({ ...e.getData(), isSelected: false, isNodeHover: false });
|
e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }, { silent: true });
|
||||||
e.setAttrByPath('line/stroke', edge_color);
|
e.setAttrByPath('line/stroke', edge_color);
|
||||||
e.setAttrByPath('line/strokeWidth', edge_width);
|
e.setAttrByPath('line/strokeWidth', edge_width);
|
||||||
});
|
});
|
||||||
@@ -885,7 +917,7 @@ export const useWorkflowGraph = ({
|
|||||||
y: bbox.y + 4,
|
y: bbox.y + 4,
|
||||||
data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' },
|
data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' },
|
||||||
});
|
});
|
||||||
parentNode.addChild(addNode);
|
parentNode.addChild(addNode, { silent: true });
|
||||||
graphRef.current!.addEdge({
|
graphRef.current!.addEdge({
|
||||||
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
|
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
|
||||||
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
|
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
|
||||||
@@ -1112,7 +1144,7 @@ export const useWorkflowGraph = ({
|
|||||||
graphRef.current?.getConnectedEdges(node).forEach(edge => {
|
graphRef.current?.getConnectedEdges(node).forEach(edge => {
|
||||||
if (!edge.getData()?.isSelected) {
|
if (!edge.getData()?.isSelected) {
|
||||||
edge.setAttrByPath('line/stroke', edge_selected_color);
|
edge.setAttrByPath('line/stroke', edge_selected_color);
|
||||||
edge.setData({ ...edge.getData(), isNodeHover: true });
|
edge.setData({ ...edge.getData(), isNodeHover: true }, { silent: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1120,7 +1152,7 @@ export const useWorkflowGraph = ({
|
|||||||
graphRef.current?.getConnectedEdges(node).forEach(edge => {
|
graphRef.current?.getConnectedEdges(node).forEach(edge => {
|
||||||
if (!edge.getData()?.isSelected) {
|
if (!edge.getData()?.isSelected) {
|
||||||
edge.setAttrByPath('line/stroke', edge_color);
|
edge.setAttrByPath('line/stroke', edge_color);
|
||||||
edge.setData({ ...edge.getData(), isNodeHover: false });
|
edge.setData({ ...edge.getData(), isNodeHover: false }, { silent: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1202,8 +1234,8 @@ export const useWorkflowGraph = ({
|
|||||||
// Delete selected nodes and edges
|
// Delete selected nodes and edges
|
||||||
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
|
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
|
||||||
// Undo / Redo
|
// Undo / Redo
|
||||||
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; });
|
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { undo(); return false; });
|
||||||
graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; });
|
graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { redo(); return false; });
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1269,14 +1301,14 @@ export const useWorkflowGraph = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (dragData.type === 'loop' || dragData.type === 'iteration') {
|
if (dragData.type === 'loop' || dragData.type === 'iteration') {
|
||||||
graphRef.current.startBatch('add-group')
|
graph.disableHistory()
|
||||||
const parentNode = graphRef.current.addNode({
|
const parentNode = graphRef.current.addNode({
|
||||||
...graphNodeLibrary[dragData.type],
|
...graphNodeLibrary[dragData.type],
|
||||||
x: point.x - 150,
|
x: point.x - 150,
|
||||||
y: point.y - 100,
|
y: point.y - 100,
|
||||||
id: cleanNodeData.id,
|
id: cleanNodeData.id,
|
||||||
data: { ...cleanNodeData, isGroup: true },
|
data: { ...cleanNodeData, isGroup: true },
|
||||||
});
|
})
|
||||||
const parentBBox = parentNode.getBBox()
|
const parentBBox = parentNode.getBBox()
|
||||||
const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||||
const cycleStartNode = graphRef.current.addNode({
|
const cycleStartNode = graphRef.current.addNode({
|
||||||
@@ -1292,16 +1324,28 @@ export const useWorkflowGraph = ({
|
|||||||
y: parentBBox.y + 70 + 4,
|
y: parentBBox.y + 70 + 4,
|
||||||
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id },
|
data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id },
|
||||||
})
|
})
|
||||||
parentNode.addChild(cycleStartNode)
|
parentNode.addChild(cycleStartNode, { silent: true })
|
||||||
parentNode.addChild(addNode)
|
parentNode.addChild(addNode, { silent: true })
|
||||||
graphRef.current.addEdge({
|
const newEdge = graphRef.current.addEdge({
|
||||||
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
|
source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
|
||||||
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
|
target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
|
||||||
...edgeAttrs,
|
...edgeAttrs,
|
||||||
})
|
})
|
||||||
cycleStartNode.toFront()
|
cycleStartNode.toFront()
|
||||||
addNode.toFront()
|
addNode.toFront()
|
||||||
graphRef.current.stopBatch('add-group')
|
graph.enableHistory()
|
||||||
|
// Manually push a single batch frame covering all 4 cells into undoStack
|
||||||
|
const history = graph.getPlugin('history') as History
|
||||||
|
const makeBatchCmd = (cell: any) => ({
|
||||||
|
batch: true,
|
||||||
|
event: 'cell:added',
|
||||||
|
data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() },
|
||||||
|
options: {},
|
||||||
|
})
|
||||||
|
const batchFrame = [parentNode, cycleStartNode, addNode, newEdge].map(makeBatchCmd)
|
||||||
|
;(history as any).undoStack.push(batchFrame)
|
||||||
|
;(history as any).redoStack = []
|
||||||
|
graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-group' } })
|
||||||
} else if (dragData.type === 'if-else') {
|
} else if (dragData.type === 'if-else') {
|
||||||
// Create condition node
|
// Create condition node
|
||||||
graphRef.current.addNode({
|
graphRef.current.addNode({
|
||||||
@@ -1548,8 +1592,80 @@ export const useWorkflowGraph = ({
|
|||||||
return userVars
|
return userVars
|
||||||
}
|
}
|
||||||
|
|
||||||
const undo = () => graphRef.current?.undo()
|
const clearHistoryRecords = () => {
|
||||||
const redo = () => graphRef.current?.redo()
|
setHistoryRecords([])
|
||||||
|
lastHistoryRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStackCellIds = (cmds: any): string[] => {
|
||||||
|
const arr = Array.isArray(cmds) ? cmds : [cmds]
|
||||||
|
return [...new Set(arr.map((c: any) => c.data?.id).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSkippableFrame = (frame: any): boolean => {
|
||||||
|
const arr = Array.isArray(frame) ? frame : [frame]
|
||||||
|
return arr.every((c: any) => ['zIndex', 'attrs', 'tools'].includes(c.data?.key))
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = () => {
|
||||||
|
const history = graphRef.current?.getPlugin('history') as History | undefined
|
||||||
|
if (!history || history.getUndoSize() === 0) return
|
||||||
|
const undoStack = (history as any).undoStack as any[]
|
||||||
|
isSyncingRef.current = true
|
||||||
|
while (undoStack.length > 0 && isSkippableFrame(undoStack[undoStack.length - 1])) {
|
||||||
|
graphRef.current!.undo()
|
||||||
|
}
|
||||||
|
if (undoStack.length === 0) {
|
||||||
|
isSyncingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const topIds = getStackCellIds(undoStack[undoStack.length - 1])
|
||||||
|
graphRef.current!.undo()
|
||||||
|
while (undoStack.length > 0) {
|
||||||
|
if (isSkippableFrame(undoStack[undoStack.length - 1])) {
|
||||||
|
graphRef.current!.undo()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const nextIds = getStackCellIds(undoStack[undoStack.length - 1])
|
||||||
|
if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) {
|
||||||
|
graphRef.current!.undo()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isSyncingRef.current = false
|
||||||
|
syncChildRelationships()
|
||||||
|
}
|
||||||
|
|
||||||
|
const redo = () => {
|
||||||
|
const history = graphRef.current?.getPlugin('history') as History | undefined
|
||||||
|
if (!history || history.getRedoSize() === 0) return
|
||||||
|
const redoStack = (history as any).redoStack as any[]
|
||||||
|
isSyncingRef.current = true
|
||||||
|
while (redoStack.length > 0 && isSkippableFrame(redoStack[redoStack.length - 1])) {
|
||||||
|
graphRef.current!.redo()
|
||||||
|
}
|
||||||
|
if (redoStack.length === 0) {
|
||||||
|
isSyncingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const topIds = getStackCellIds(redoStack[redoStack.length - 1])
|
||||||
|
graphRef.current!.redo()
|
||||||
|
while (redoStack.length > 0) {
|
||||||
|
if (isSkippableFrame(redoStack[redoStack.length - 1])) {
|
||||||
|
graphRef.current!.redo()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const nextIds = getStackCellIds(redoStack[redoStack.length - 1])
|
||||||
|
if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) {
|
||||||
|
graphRef.current!.redo()
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isSyncingRef.current = false
|
||||||
|
syncChildRelationships()
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
|
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
|
||||||
const { statement = '' } = value?.opening_statement || {}
|
const { statement = '' } = value?.opening_statement || {}
|
||||||
@@ -1593,7 +1709,7 @@ export const useWorkflowGraph = ({
|
|||||||
// Reset all node execution status on every chatHistory change
|
// Reset all node execution status on every chatHistory change
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const data = node.getData();
|
const data = node.getData();
|
||||||
node.setData({ ...data, executionStatus: '' });
|
node.setData({ ...data, executionStatus: '' }, { silent: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant');
|
const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant');
|
||||||
@@ -1602,7 +1718,7 @@ export const useWorkflowGraph = ({
|
|||||||
if (typeof sub.status === 'string') {
|
if (typeof sub.status === 'string') {
|
||||||
const node = nodes.find(n => n.getData()?.id === sub.node_id);
|
const node = nodes.find(n => n.getData()?.id === sub.node_id);
|
||||||
if (node) {
|
if (node) {
|
||||||
node.setData({ ...node.getData(), executionStatus: sub.status });
|
node.setData({ ...node.getData(), executionStatus: sub.status }, { silent: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1635,5 +1751,7 @@ export const useWorkflowGraph = ({
|
|||||||
canRedo,
|
canRedo,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
historyRecords,
|
||||||
|
clearHistoryRecords,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -114,3 +114,12 @@ export interface ChatVariable {
|
|||||||
export interface AddChatVariableRef {
|
export interface AddChatVariableRef {
|
||||||
handleOpen: (value?: ChatVariable) => void;
|
handleOpen: (value?: ChatVariable) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HistoryActionType = 'add' | 'remove' | 'change' | 'undo' | 'redo' | 'batch'
|
||||||
|
|
||||||
|
export interface HistoryRecord {
|
||||||
|
type: HistoryActionType;
|
||||||
|
timestamp: number;
|
||||||
|
batchName?: string;
|
||||||
|
cellIds?: string[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user