diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index 9b9d2236..dd0ab23d 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:31:30 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-06 11:43:58 + * @Last Modified time: 2026-03-30 11:55:10 */ import { useState } from 'react'; import { Popover, Flex } from 'antd'; @@ -173,7 +173,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { 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-[#DFE4ED] rb:flex rb:items-center rb:justify-center', { + 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 })} diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index 12ae6ca0..516b5125 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -48,7 +48,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { return (
diff --git a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx index 0f963adc..4a29531f 100644 --- a/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx +++ b/web/src/views/Workflow/components/Nodes/GroupStartNode.tsx @@ -3,7 +3,7 @@ import type { ReactShapeConfig } from '@antv/x6-react-shape'; const GroupStartNode: ReactShapeConfig['component'] = () => { return ( -
+
); diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index b8c2ea0c..29c683cc 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -122,7 +122,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { return (
diff --git a/web/src/views/Workflow/components/Nodes/NormalNode.tsx b/web/src/views/Workflow/components/Nodes/NormalNode.tsx index 340e95dc..12e89cca 100644 --- a/web/src/views/Workflow/components/Nodes/NormalNode.tsx +++ b/web/src/views/Workflow/components/Nodes/NormalNode.tsx @@ -12,7 +12,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => { return (
diff --git a/web/src/views/Workflow/components/PortClickHandler.tsx b/web/src/views/Workflow/components/PortClickHandler.tsx index 2cc0c3c5..13ad6b98 100644 --- a/web/src/views/Workflow/components/PortClickHandler.tsx +++ b/web/src/views/Workflow/components/PortClickHandler.tsx @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-09 18:30:28 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-24 11:11:56 + * @Last Modified time: 2026-03-30 15:14:02 */ import { useEffect, useState } from 'react'; import { Popover } from 'antd'; @@ -20,13 +20,15 @@ const PortClickHandler: React.FC = ({ graph }) => { const [sourceNode, setSourceNode] = useState(null); const [sourcePort, setSourcePort] = useState(''); const [tempElement, setTempElement] = useState(null); + const [edgeInsertion, setEdgeInsertion] = useState(null); useEffect(() => { const handlePortClick = (event: CustomEvent) => { - const { node, port, element, rect } = event.detail; + const { node, port, element, rect, edgeInsertion } = event.detail; setSourceNode(node); setSourcePort(port); setTempElement(element); + setEdgeInsertion(edgeInsertion || null); setPopoverPosition({ x: rect.left, y: rect.top }); setPopoverVisible(true); }; @@ -72,15 +74,47 @@ const PortClickHandler: React.FC = ({ graph }) => { const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortGroup = sourcePortInfo?.group || sourcePort; - // If add-node position exists, use it; otherwise calculate new position + // Calculate new node position let newX, newY; - if (addNodePosition) { + if (edgeInsertion) { + // Edge insertion: place new node on the same row as target, between source and target + const targetBBox = edgeInsertion.targetCell.getBBox(); + const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width); + const requiredSpace = nodeWidth + horizontalSpacing * 4; + + // New node x: right after source + spacing + 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) { + const shiftX = requiredSpace - gap; + const visited = new Set(); + const shiftDownstream = (cell: any) => { + const cellId = cell.id; + if (visited.has(cellId)) return; + visited.add(cellId); + const pos = cell.getPosition(); + cell.setPosition(pos.x + shiftX, pos.y); + // Recursively shift nodes connected from right ports + graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { + const tId = e.getTargetCellId(); + if (tId && !visited.has(tId)) { + const tCell = graph.getCellById(tId); + if (tCell?.isNode()) shiftDownstream(tCell); + } + }); + }; + shiftDownstream(edgeInsertion.targetCell); + } + } else if (addNodePosition) { newX = addNodePosition.x; newY = addNodePosition.y; } else { // Determine node placement direction based on port position if (sourcePortGroup === 'left') { - // Left port: add node to the left + // Left port: add node to the left newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; newY = sourceBBox.y; } else { @@ -91,7 +125,7 @@ const PortClickHandler: React.FC = ({ graph }) => { // Check if position overlaps with existing nodes (only consider connected nodes) const checkOverlap = (x: number, y: number) => { - // Get nodes connected to the source node + // Get nodes connected to the source node const connectedNodes = new Set(); graph.getConnectedEdges(sourceNode).forEach((edge: any) => { const sourceId = edge.getSourceCellId(); @@ -108,7 +142,7 @@ const PortClickHandler: React.FC = ({ graph }) => { y + nodeHeight < bbox.y || y > bbox.y + bbox.height); }); }; - + // If position is occupied, search downward for empty space while (checkOverlap(newX, newY)) { newY += nodeHeight + verticalSpacing; @@ -140,28 +174,51 @@ const PortClickHandler: React.FC = ({ graph }) => { } } + // Edge insertion: remove old edge immediately before creating new edges + if (edgeInsertion) { + const { edge: oldEdge } = edgeInsertion; + if (oldEdge.id && graph.getCellById(oldEdge.id)) { + graph.removeCell(oldEdge.id); + } else { + graph.removeEdge(oldEdge); + } + } + // Create edge connection setTimeout(() => { - const targetPorts = newNode.getPorts(); - let targetPort; - - if (sourcePortGroup === 'left') { + const newPorts = newNode.getPorts(); + + if (edgeInsertion) { + // Edge insertion: create source→new and new→target edges + const { targetCell, targetPort: origTargetPort } = edgeInsertion; + const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + graph.addEdge({ + source: { cell: sourceNode.id, port: sourcePort }, + target: { cell: newNode.id, port: newLeftPort }, + ...edgeAttrs + }); + graph.addEdge({ + source: { cell: newNode.id, port: newRightPort }, + target: { cell: targetCell.id, port: origTargetPort }, + ...edgeAttrs + }); + setEdgeInsertion(null); + } else if (sourcePortGroup === 'left') { // Connect from left port to new node's right side - targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right'; + const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right'; graph.addEdge({ source: { cell: newNode.id, port: targetPort }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs - // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0 }); } else { // Connect from right port to new node's left side - targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; + const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left'; graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: targetPort }, ...edgeAttrs - // zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0 }); } diff --git a/web/src/views/Workflow/constant.ts b/web/src/views/Workflow/constant.ts index d62ef06f..50b92696 100644 --- a/web/src/views/Workflow/constant.ts +++ b/web/src/views/Workflow/constant.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:06:18 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 18:30:52 + * @Last Modified time: 2026-03-30 15:11:56 */ import LoopNode from './components/Nodes/LoopNode'; import NormalNode from './components/Nodes/NormalNode'; @@ -642,6 +642,8 @@ interface NodeConfig { /** Edge color for normal state */ export const edge_color = '#D4D5D9'; +/** Edge color for hover state */ +export const edge_hover_color = '#2E90FA'; /** Edge color for selected state */ export const edge_selected_color = '#171719' export const edge_width = 2; @@ -884,4 +886,70 @@ export const edgeAttrs = { }, }, }, +} + +/** + * Edge hover tool: circular "+" button shown at midpoint on hover + */ +export const edgeHoverTool = { + name: 'button', + args: { + markup: [ + { + tagName: 'circle', + selector: 'button', + attrs: { + r: 6, + stroke: port_color, + strokeWidth: edge_width, + fill: port_color, + cursor: 'pointer', + }, + }, + { + tagName: 'text', + textContent: '+', + selector: 'icon', + attrs: { + fontSize: 12, + fontWeight: 'bold', + fill: '#FFFFFF', + textAnchor: 'middle', + textVerticalAnchor: 'middle', + pointerEvents: 'none', + y: '0.3em', + }, + }, + ], + distance: 0.5, + offset: { x: 0, y: 0 }, + onClick({ e, cell: edge }: any) { + e.stopPropagation(); + const graph = edge.model?.graph; + if (!graph) return; + const sourceCell = graph.getCellById(edge.getSourceCellId()); + const targetCell = graph.getCellById(edge.getTargetCellId()); + const sourcePort = edge.getSourcePortId(); + const targetPort = edge.getTargetPortId(); + if (!sourceCell || !targetCell) return; + const rect = (e.target as HTMLElement).getBoundingClientRect(); + const tempDiv = document.createElement('div'); + tempDiv.style.position = 'fixed'; + tempDiv.style.left = rect.left + 'px'; + tempDiv.style.top = rect.top + 'px'; + tempDiv.style.width = '1px'; + tempDiv.style.height = '1px'; + tempDiv.style.zIndex = '9999'; + document.body.appendChild(tempDiv); + window.dispatchEvent(new CustomEvent('port:click', { + detail: { + node: sourceCell, + port: sourcePort, + element: tempDiv, + rect, + edgeInsertion: { edge, sourceCell, targetCell, sourcePort, targetPort } + } + })); + }, + }, } \ No newline at end of file diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index 626165da..4059c264 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,7 +2,7 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-03-27 18:14:38 + * @Last Modified time: 2026-03-30 15:08:14 */ import { useRef, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; @@ -12,7 +12,7 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from ' import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; -import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant'; +import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edgeHoverTool, edge_color, edge_hover_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant'; import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types'; import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application' import { useUser } from '@/store/user'; @@ -881,12 +881,21 @@ export const useWorkflowGraph = ({ }); // Use plugins setupPlugins(); - // Listen to edge mouseleave event + // Listen to edge mouseenter event: show hover style and add button + graphRef.current.on('edge:mouseenter', ({ edge }: { edge: Edge }) => { + if (edge.getAttrByPath('line/stroke') !== edge_selected_color) { + edge.setAttrByPath('line/stroke', edge_hover_color); + edge.setAttrByPath('line/strokeWidth', edge_width); + } + edge.addTools([edgeHoverTool]); + }); + // Listen to edge mouseleave event: revert style and remove add button graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => { if (edge.getAttrByPath('line/stroke') !== edge_selected_color) { edge.setAttrByPath('line/stroke', edge_color); edge.setAttrByPath('line/strokeWidth', edge_width); } + edge.removeTools(); }); // Listen to node selection event graphRef.current.on('node:click', nodeClick);