feat(web) workflow edge center add add tool
This commit is contained in:
@@ -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
|
||||
})}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}));
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user