221 lines
8.9 KiB
TypeScript
221 lines
8.9 KiB
TypeScript
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;
|
||
|