From e53be0765ade173e1a51c9c4c8788ce8714848bd Mon Sep 17 00:00:00 2001 From: zhaoying Date: Wed, 6 May 2026 10:36:02 +0800 Subject: [PATCH] fix(web): history undo/redo --- .../components/Properties/CaseList/index.tsx | 176 +++++++----------- .../Properties/CategoryList/index.tsx | 138 +++++--------- .../views/Workflow/hooks/useWorkflowGraph.ts | 94 ++++++++-- 3 files changed, 192 insertions(+), 216 deletions(-) diff --git a/web/src/views/Workflow/components/Properties/CaseList/index.tsx b/web/src/views/Workflow/components/Properties/CaseList/index.tsx index a9da1457..1f795a5a 100644 --- a/web/src/views/Workflow/components/Properties/CaseList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CaseList/index.tsx @@ -355,14 +355,13 @@ const CaseList: FC = ({ // Update node ports based on case count changes (add/remove cases) const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { if (!selectedNode || !graphRef?.current) return; - - // Get current port count to determine if it's an add or remove operation - const currentPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right'); - const currentCaseCount = currentPorts.length - 1; // Exclude ELSE port + const graph = graphRef.current; + + const currentRightPorts = selectedNode.getPorts().filter((port: any) => port.group === 'right'); + const currentCaseCount = currentRightPorts.length - 1; const isAddingCase = removedCaseIndex === undefined && caseCount > currentCaseCount; - - // Save existing edge connections (including left-side port connections) - const existingEdges = graphRef.current.getEdges().filter((edge: any) => + + const existingEdges = graph.getEdges().filter((edge: any) => edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id ); const edgeConnections = existingEdges.map((edge: any) => ({ @@ -371,113 +370,70 @@ const CaseList: FC = ({ targetCellId: edge.getTargetCellId(), targetPortId: edge.getTargetPortId(), sourceCellId: edge.getSourceCellId(), - isIncoming: edge.getTargetCellId() === selectedNode.id + isIncoming: edge.getTargetCellId() === selectedNode.id, })); - - // Remove all existing right-side ports - const existingPorts = selectedNode.getPorts(); - existingPorts.forEach((port: any) => { - if (port.group === 'right') { - selectedNode.removePort(port.id); + + const cases = form.getFieldValue(name) || []; + const leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right'); + const newRightPorts = Array.from({ length: caseCount + 1 }, (_, i) => ({ + id: `CASE${i + 1}`, + group: 'right', + args: { x: nodeWidth, y: getConditionNodeCasePortY(cases, i) }, + })); + + graph.startBatch('update-ports'); + + existingEdges.forEach((edge: any) => graph.removeCell(edge)); + // Replace all ports in one prop call — produces a single cell:change:ports command + selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true }); + selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) }); + + edgeConnections.forEach(({sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { + if (isIncoming) { + const sourceCell = graph.getCellById(sourceCellId); + if (sourceCell) { + graph.addEdge({ + source: { cell: sourceCellId, port: sourcePortId }, + target: { cell: selectedNode.id, port: targetPortId }, + ...edgeAttrs + }); + sourceCell.toFront(); + bringLoopChildrenToFront(sourceCell); + selectedNode.toFront(); + bringLoopChildrenToFront(selectedNode); + } + return; + } + const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); + if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return; + let newPortId = sourcePortId; + + if (removedCaseIndex !== undefined) { + if (originalCaseNumber > removedCaseIndex + 1) { + newPortId = `CASE${originalCaseNumber - 1}`; + } else if (originalCaseNumber === currentCaseCount + 1) { + newPortId = `CASE${caseCount + 1}`; + } + } else if (isAddingCase && originalCaseNumber === currentCaseCount + 1) { + newPortId = `CASE${caseCount + 1}`; + } + if (newRightPorts.find((p) => p.id === newPortId)) { + const targetCell = graph.getCellById(targetCellId); + if (targetCell) { + graph.addEdge({ + source: { cell: selectedNode.id, port: newPortId }, + target: { cell: targetCellId, port: targetPortId }, + ...edgeAttrs + }); + selectedNode.toFront(); + bringLoopChildrenToFront(selectedNode); + targetCell.toFront(); + bringLoopChildrenToFront(targetCell); + } } }); - const cases = form.getFieldValue(name) || []; - selectedNode.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(cases) }); - - // Add ELIF ports - for (let i = 0; i < caseCount; i++) { - selectedNode.addPort({ - id: `CASE${i + 1}`, - group: 'right', - args: { - x: nodeWidth, - y: getConditionNodeCasePortY(cases, i), - }, - }); - } - - // Add ELSE port - selectedNode.addPort({ - id: `CASE${caseCount + 1}`, - group: 'right', - args: { - x: nodeWidth, - y: getConditionNodeCasePortY(cases, caseCount), - }, - }); - - // Restore edge connections - setTimeout(() => { - edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { - // If it's an incoming connection (left-side port), restore directly - if (isIncoming) { - const sourceCell = graphRef.current?.getCellById(sourceCellId); - if (sourceCell) { - graphRef.current?.addEdge({ - source: { cell: sourceCellId, port: sourcePortId }, - target: { cell: selectedNode.id, port: targetPortId }, - ...edgeAttrs, - }); - } - sourceCell.toFront() - selectedNode.toFront() - bringLoopChildrenToFront(sourceCell) - bringLoopChildrenToFront(selectedNode) - graphRef.current?.removeCell(edge); - return; - } - - // Handle right-side port connections - const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); - - // If it's a remove operation and the port is being removed, delete the connection - if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) { - graphRef.current?.removeCell(edge); - return; - } - - let newPortId = sourcePortId; - - // If it's a remove operation, remap port IDs - if (removedCaseIndex !== undefined) { - if (originalCaseNumber > removedCaseIndex + 1) { - // Ports after the removed port, shift numbering forward - newPortId = `CASE${originalCaseNumber - 1}`; - } - // ELSE port always maps to the new ELSE port position - else if (originalCaseNumber === currentCaseCount + 1) { - newPortId = `CASE${caseCount + 1}`; - } - } else if (isAddingCase) { - // If it's an add operation, ELSE port needs to be remapped - if (originalCaseNumber === currentCaseCount + 1) { - newPortId = `CASE${caseCount + 1}`; // New ELSE port - } - // Newly added ports don't restore any connections - } - - const newPorts = selectedNode.getPorts(); - const matchingPort = newPorts.find((port: any) => port.id === newPortId); - - if (matchingPort) { - const targetCell = graphRef.current?.getCellById(targetCellId); - if (targetCell) { - graphRef.current?.addEdge({ - source: { cell: selectedNode.id, port: newPortId }, - target: { cell: targetCellId, port: targetPortId }, - ...edgeAttrs - }); - selectedNode.toFront() - bringLoopChildrenToFront(selectedNode) - targetCell.toFront() - bringLoopChildrenToFront(targetCell) - } - } - - graphRef.current?.removeCell(edge); - }); - }, 50); + graph.stopBatch('update-ports'); }; const handleChangeLogicalOperator = (index: number) => { diff --git a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx index 8a406a7a..d4eb6a0d 100644 --- a/web/src/views/Workflow/components/Properties/CategoryList/index.tsx +++ b/web/src/views/Workflow/components/Properties/CategoryList/index.tsx @@ -42,109 +42,73 @@ const CategoryList: FC = ({ parentName, selectedNode, graphRe // Update node ports based on category count changes (add/remove categories) const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => { if (!selectedNode || !graphRef?.current) return; + const graph = graphRef.current; - // Save existing edge connections (including left-side port connections) - const existingEdges = graphRef.current.getEdges().filter((edge: any) => + const existingEdges = graph.getEdges().filter((edge: any) => edge.getSourceCellId() === selectedNode.id || edge.getTargetCellId() === selectedNode.id ); const edgeConnections = existingEdges.map((edge: any) => ({ - edge, sourcePortId: edge.getSourcePortId(), targetCellId: edge.getTargetCellId(), targetPortId: edge.getTargetPortId(), sourceCellId: edge.getSourceCellId(), - isIncoming: edge.getTargetCellId() === selectedNode.id + isIncoming: edge.getTargetCellId() === selectedNode.id, })); - // Remove all existing right-side ports - const existingPorts = selectedNode.getPorts(); - existingPorts.forEach((port: any) => { - if (port.group === 'right') { - selectedNode.removePort(port.id); - } - }); + graph.startBatch('update-ports'); + + existingEdges.forEach((edge: any) => graph.removeCell(edge)); + // Replace all ports in one prop call — produces a single cell:change:ports command + const leftPorts = selectedNode.getPorts().filter((p: any) => p.group !== 'right'); + const newRightPorts = Array.from({ length: caseCount }, (_, i) => ({ + id: `CASE${i + 1}`, + group: 'right', + args: { x: nodeWidth, y: portItemArgsY * i + conditionNodePortItemArgsY }, + })); + selectedNode.prop('ports/items', [...leftPorts, ...newRightPorts], { rewrite: true }); - // Calculate new node height: base height 88px + 30px for each additional port const newHeight = conditionNodeHeight + (caseCount - 2) * conditionNodeItemHeight; + selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight }); - selectedNode.prop('size', { width: nodeWidth, height: newHeight < conditionNodeHeight ? conditionNodeHeight : newHeight }) - - // Update right port x position - const currentPorts = selectedNode.getPorts(); - currentPorts.forEach(port => { - if (port.group === 'right' && port.args) { - selectedNode.portProp(port.id!, 'args/x', nodeWidth); + edgeConnections.forEach(({ sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { + if (isIncoming) { + const sourceCell = graph.getCellById(sourceCellId); + if (sourceCell) { + graph.addEdge({ + source: { cell: sourceCellId, port: sourcePortId }, + target: { cell: selectedNode.id, port: targetPortId }, + ...edgeAttrs + }); + sourceCell.toFront(); + bringLoopChildrenToFront(sourceCell); + selectedNode.toFront(); + bringLoopChildrenToFront(selectedNode); + } + return; + } + const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); + if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) return; + let newPortId = sourcePortId; + if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) { + newPortId = `CASE${originalCaseNumber - 1}`; + } + if (newRightPorts.find((p) => p.id === newPortId)) { + const targetCell = graph.getCellById(targetCellId); + if (targetCell) { + graph.addEdge({ + source: { cell: selectedNode.id, port: newPortId }, + target: { cell: targetCellId, port: targetPortId }, + ...edgeAttrs + }); + selectedNode.toFront(); + bringLoopChildrenToFront(selectedNode); + targetCell.toFront(); + bringLoopChildrenToFront(targetCell); + } } }); - // Add category ports - for (let i = 0; i < caseCount; i++) { - selectedNode.addPort({ - id: `CASE${i + 1}`, - group: 'right', - args: { - x: nodeWidth, - y: portItemArgsY * i + conditionNodePortItemArgsY, - }, - }); - } - // Restore edge connections - setTimeout(() => { - edgeConnections.forEach(({ edge, sourcePortId, targetCellId, targetPortId, sourceCellId, isIncoming }: any) => { - graphRef.current?.removeCell(edge); - - // If it's an incoming connection (left-side port), restore directly - if (isIncoming) { - const sourceCell = graphRef.current?.getCellById(sourceCellId); - if (sourceCell) { - graphRef.current?.addEdge({ - source: { cell: sourceCellId, port: sourcePortId }, - target: { cell: selectedNode.id, port: targetPortId }, - ...edgeAttrs - }); - sourceCell.toFront() - bringLoopChildrenToFront(sourceCell) - selectedNode.toFront() - bringLoopChildrenToFront(selectedNode) - } - return; - } - - // Handle right-side port connections - const originalCaseNumber = parseInt(sourcePortId.match(/CASE(\d+)/)?.[1] || '0'); - - // If it's a removed port, don't recreate the connection - if (removedCaseIndex !== undefined && originalCaseNumber === removedCaseIndex + 1) { - return; - } - - let newPortId = sourcePortId; - - // If a port was removed, remap subsequent port IDs - if (removedCaseIndex !== undefined && originalCaseNumber > removedCaseIndex + 1) { - newPortId = `CASE${originalCaseNumber - 1}`; - } - - // Check if the new port exists - const newPorts = selectedNode.getPorts(); - const matchingPort = newPorts.find((port: any) => port.id === newPortId); - - if (matchingPort) { - const targetCell = graphRef.current?.getCellById(targetCellId); - if (targetCell) { - graphRef.current?.addEdge({ - source: { cell: selectedNode.id, port: newPortId }, - target: { cell: targetCellId, port: targetPortId }, - ...edgeAttrs - }); - selectedNode.toFront() - bringLoopChildrenToFront(selectedNode) - targetCell.toFront() - bringLoopChildrenToFront(targetCell) - } - } - }); - }, 50); + graph.stopBatch('update-ports'); }; const handleAddCategory = (addFunc: Function) => { diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 0fda2935..ef29f26a 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -124,9 +124,7 @@ export const useWorkflowGraph = ({ const [canRedo, setCanRedo] = useState(false) const [historyRecords, setHistoryRecords] = useState([]) const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null) - const undoRef = useRef<() => void>(() => {}) - const redoRef = useRef<() => void>(() => {}) - const syncChildRelationshipsRef = useRef<() => void>(() => {}) + const syncChildRelationshipsRef = useRef<() => void>(() => { }) const isSyncingRef = useRef(false) useEffect(() => { if (!graphRef.current) return @@ -532,24 +530,82 @@ export const useWorkflowGraph = ({ const graph = graphRef.current graph.disableHistory() graph.getNodes().forEach(node => { - const cycleId = node.getData()?.cycle - if (!cycleId) return - const parentNode = graph.getCellById(cycleId) as Node | null - if (!parentNode) return - if (!parentNode.getChildren()?.some(c => c.id === node.id)) { - parentNode.addChild(node, { silent: true }) - } - }) - graph.getNodes().forEach(node => { + const nodeData = node.getData() const children = node.getChildren() - if (!children?.length) return - children.forEach(child => { - if (!child.isNode()) return - const childCycleId = (child as Node).getData?.()?.cycle - if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { - node.removeChild(child, { silent: true }) + + const cycleId = nodeData?.cycle + + if (cycleId) { + const parentNode = graph.getCellById(cycleId) as Node | null + if (!parentNode) return + if (!parentNode.getChildren()?.some(c => c.id === node.id)) { + parentNode.addChild(node, { silent: true }) } - }) + } + + if (nodeData.type === 'if-else') { + const rightPorts = node.getPorts().filter(p => p.group === 'right') + const caseCount = rightPorts.length - 1 // last port is ELSE + const currentCases: any[] = nodeData.config?.cases?.defaultValue ?? [] + const newCases = caseCount !== currentCases.length + ? Array.from({ length: caseCount }, (_, i) => currentCases[i] ?? { logical_operator: 'and', expressions: [] }) + : currentCases + if (caseCount !== currentCases.length) { + node.setData({ + ...nodeData, + config: { ...nodeData.config, cases: { ...nodeData.config.cases, defaultValue: newCases } } + }, { deep: false, silent: true }) + } + // Sync node height and port Y positions + node.prop('size', { width: nodeWidth, height: calcConditionNodeTotalHeight(newCases) }) + newCases.forEach((_c: any, i: number) => { + node.portProp(`CASE${i + 1}`, 'args/y', getConditionNodeCasePortY(newCases, i)) + }) + node.portProp(`CASE${newCases.length + 1}`, 'args/y', getConditionNodeCasePortY(newCases, newCases.length)) + node.toFront() + graph.getEdges().filter(e => e.getSourceCellId() === node.id).forEach(e => { + const tgt = graph.getCellById(e.getTargetCellId()) + tgt?.toFront() + }) + } else if (nodeData.type === 'question-classifier') { + const rightPorts = node.getPorts().filter(p => p.group === 'right') + const currentCategories: any[] = nodeData.config?.categories?.defaultValue ?? [] + const categoryCount = rightPorts.length + const newCategories = categoryCount !== currentCategories.length + ? rightPorts.map((port, i) => { + if (currentCategories[i]) return currentCategories[i] + const edge = graph.getEdges().find(e => e.getSourceCellId() === node.id && e.getSourcePortId() === port.id) + return edge ? { name: '' } : {} + }) + : currentCategories + if (categoryCount !== currentCategories.length) { + node.setData({ + ...nodeData, + config: { ...nodeData.config, categories: { ...nodeData.config.categories, defaultValue: [...newCategories] } } + }, { deep: false, silent: true }) + } + // Sync node height and port Y positions + const newHeight = conditionNodeHeight + (categoryCount - 2) * conditionNodeItemHeight + node.prop('size', { width: nodeWidth, height: Math.max(newHeight, conditionNodeHeight) }) + rightPorts.forEach((_p, i) => { + node.portProp(`CASE${i + 1}`, 'args/y', portItemArgsY * i + conditionNodePortItemArgsY) + }) + node.toFront() + graph.getEdges().filter(e => e.getSourceCellId() === node.id).forEach(e => { + const tgt = graph.getCellById(e.getTargetCellId()) + tgt?.toFront() + }) + } + + if (children?.length) { + children.forEach(child => { + if (!child.isNode()) return + const childCycleId = (child as Node).getData?.()?.cycle + if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { + node.removeChild(child, { silent: true }) + } + }) + } }) resizeGroupNodes(graph) graph.getEdges().forEach(edge => {