feat(web) workflow edge center add add tool

This commit is contained in:
zhaoying
2026-03-30 15:15:20 +08:00
parent f9f302dd2a
commit 13e35ed122
8 changed files with 159 additions and 25 deletions

View File

@@ -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
})}

View File

@@ -48,7 +48,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
'rb:border-[#FCFCFD]': !data.isSelected
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">

View File

@@ -3,7 +3,7 @@ import type { ReactShapeConfig } from '@antv/x6-react-shape';
const GroupStartNode: ReactShapeConfig['component'] = () => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-xl 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')}>
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:border rb:rounded-xl 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')}>
<div className="rb:size-5 rb:bg-cover rb:bg-[url('@/assets/images/workflow/start.svg')]" />
</div>
);

View File

@@ -122,7 +122,7 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
'rb:border-[#FCFCFD]': !data.isSelected
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">

View File

@@ -12,7 +12,7 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
'rb:border-[#FCFCFD]': !data.isSelected
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">

View File

@@ -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<PortClickHandlerProps> = ({ graph }) => {
const [sourceNode, setSourceNode] = useState<any>(null);
const [sourcePort, setSourcePort] = useState<string>('');
const [tempElement, setTempElement] = useState<HTMLElement | null>(null);
const [edgeInsertion, setEdgeInsertion] = useState<any>(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<PortClickHandlerProps> = ({ 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<string>();
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<PortClickHandlerProps> = ({ 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<PortClickHandlerProps> = ({ 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<PortClickHandlerProps> = ({ 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
});
}

View File

@@ -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 }
}
}));
},
},
}

View File

@@ -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);