deploy-ease-platform/frontend/src/pages/Workflow/Design/components/CustomEdge.tsx
2025-11-10 15:55:25 +08:00

221 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo, useCallback } from 'react';
import { BaseEdge, EdgeLabelRenderer, EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import type { FlowNode, ControlPoint } from '../types';
import { convertToDisplayName } from '@/utils/workflow/variableConversion';
/**
* 自定义边组件
* 支持hover高亮、样式优化和拖动改变路径
*/
const CustomEdge: React.FC<EdgeProps> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
label,
selected,
data,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const { getNodes, setEdges, screenToFlowPosition } = useReactFlow();
// 从 data 中获取自定义的控制点
const customControlPoint = data?.controlPoint as ControlPoint | undefined;
// 将 label 中的 UUID 转换为节点名
const displayLabel = useMemo(() => {
if (!label || typeof label !== 'string') {
return label;
}
const allNodes = getNodes() as FlowNode[];
return convertToDisplayName(label, allNodes);
}, [label, getNodes]);
// 计算三个拐点vertices优先用数据中的 vertices否则从旧的 controlPoint 推导最后用默认1/4、1/2、3/4
const vertices: ControlPoint[] = useMemo(() => {
const dataVertices = (data as any)?.vertices as ControlPoint[] | undefined;
if (Array.isArray(dataVertices) && dataVertices.length === 3) return dataVertices;
const cp = customControlPoint;
if (cp) {
return [
{ x: (sourceX + cp.x) / 2, y: (sourceY + cp.y) / 2 },
{ x: cp.x, y: cp.y },
{ x: (cp.x + targetX) / 2, y: (cp.y + targetY) / 2 },
];
}
return [
{ x: sourceX + (targetX - sourceX) * 0.25, y: sourceY + (targetY - sourceY) * 0.25 },
{ x: (sourceX + targetX) / 2, y: (sourceY + targetY) / 2 },
{ x: sourceX + (targetX - sourceX) * 0.75, y: sourceY + (targetY - sourceY) * 0.75 },
];
}, [data, customControlPoint, sourceX, sourceY, targetX, targetY]);
// 若数据中缺少 vertices则在首次渲染时写回确保后续保存能持久化
React.useEffect(() => {
const hasDataVertices = Array.isArray((data as any)?.vertices) && (data as any).vertices.length === 3;
if (!hasDataVertices) {
setEdges((eds) => eds.map(ed => ed.id === id ? { ...ed, data: { ...ed.data, vertices } } : ed));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 生成路径:使用 Catmull-Rom -> Cubic Bezier 插值,曲线严格经过拐点
const [edgePath, labelX, labelY] = useMemo(() => {
// 拼接点序列起点、3个拐点、终点
const pts = [
{ x: sourceX, y: sourceY },
vertices[0],
vertices[1],
vertices[2],
{ x: targetX, y: targetY },
];
// Catmull-Rom 转三次贝塞尔C1 = P0 + (P1 - P_{-1})/6, C2 = P1 - (P2 - P0)/6
const segs: string[] = [];
for (let i = 0; i < pts.length - 1; i++) {
const p_1 = i === 0 ? pts[i] : pts[i - 1];
const p0 = pts[i];
const p1 = pts[i + 1];
const p2 = i + 2 < pts.length ? pts[i + 2] : p1;
const c1x = p0.x + (p1.x - p_1.x) / 6;
const c1y = p0.y + (p1.y - p_1.y) / 6;
const c2x = p1.x - (p2.x - p0.x) / 6;
const c2y = p1.y - (p2.y - p0.y) / 6;
segs.push(`C ${c1x},${c1y} ${c2x},${c2y} ${p1.x},${p1.y}`);
}
const path = `M ${pts[0].x},${pts[0].y} ${segs.join(' ')}`;
// 标签显示在第二个拐点
return [path, vertices[1].x, vertices[1].y] as [string, number, number];
}, [vertices, sourceX, sourceY, targetX, targetY]);
// 拖动处理(支持第 idx 个拐点)
const handleVertexMouseDown = useCallback((idx: number, e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
const handleMove = (moveEvent: MouseEvent) => {
const flowPosition = screenToFlowPosition({ x: moveEvent.clientX, y: moveEvent.clientY });
setEdges((eds) => eds.map(ed => {
if (ed.id !== id) return ed;
const old = (ed as any).data?.vertices as ControlPoint[] | undefined;
const base = Array.isArray(old) && old.length === 3 ? [...old] : vertices.slice();
base[idx] = { x: flowPosition.x, y: flowPosition.y };
return { ...ed, data: { ...ed.data, vertices: base } } as any;
}));
};
const handleUp = () => {
setIsDragging(false);
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
}, [id, setEdges, screenToFlowPosition, vertices]);
// 根据状态确定样式
const edgeStyle = {
...style,
stroke: selected ? '#3b82f6' : isHovered ? '#64748b' : '#94a3b8',
strokeWidth: selected ? 3 : isHovered ? 2.5 : 2,
transition: 'all 0.2s ease',
};
// ✅ 修复:确保 markerEnd 是对象后再 spread
const markerEndStyle = (() => {
if (!markerEnd || typeof markerEnd !== 'object') return markerEnd;
const markerObj = markerEnd as Record<string, any>;
if (selected) return { ...markerObj, color: '#3b82f6' };
if (isHovered) return { ...markerObj, color: '#64748b' };
return markerEnd;
})();
return (
<>
<BaseEdge
id={id}
path={edgePath}
style={edgeStyle}
markerEnd={markerEndStyle as any}
/>
{/* 增加点击区域 */}
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={20}
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
/>
{/* 控制点三个拐点直接在SVG中渲染可拖动 */}
{(selected || isHovered) && (
<g>
{vertices.map((pt, idx) => (
<g key={`v-${idx}`}>
<circle
cx={pt.x}
cy={pt.y}
r={8}
fill={isDragging ? '#2563eb' : '#3b82f6'}
stroke="white"
strokeWidth={2}
style={{ cursor: isDragging ? 'grabbing' : 'grab', filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))', pointerEvents: 'all' }}
onMouseDown={(e) => handleVertexMouseDown(idx, e)}
/>
<circle
cx={pt.x}
cy={pt.y}
r={15}
fill="transparent"
style={{ cursor: isDragging ? 'grabbing' : 'grab', pointerEvents: 'all' }}
onMouseDown={(e) => handleVertexMouseDown(idx, e)}
/>
</g>
))}
</g>
)}
{/* 边标签 */}
{displayLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: '12px',
fontWeight: '500',
color: selected ? '#3b82f6' : '#64748b',
background: 'white',
padding: '4px 10px',
borderRadius: '6px',
border: `1px solid ${selected ? '#3b82f6' : '#e5e7eb'}`,
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
pointerEvents: 'all',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayLabel}
</div>
</EdgeLabelRenderer>
)}
</>
);
};
export default CustomEdge;