259 lines
7.3 KiB
TypeScript

/**
* 编辑连接线组件
* 实现从编辑点到输入框的弧线连接
*/
import React, { useMemo } from 'react';
import { motion } from 'framer-motion';
import { ConnectionPathParams, InputBoxPosition } from './types';
interface EditConnectionProps {
/** 起始点坐标(编辑点位置) */
startPoint: { x: number; y: number };
/** 结束点坐标(输入框位置) */
endPoint: { x: number; y: number };
/** 容器尺寸 */
containerSize: { width: number; height: number };
/** 连接线样式 */
style?: {
color?: string;
strokeWidth?: number;
dashArray?: string;
};
/** 弧线弯曲程度 */
curvature?: number;
/** 是否显示动画 */
animated?: boolean;
}
/**
* 计算弧线路径
*/
function calculateCurvePath({
start,
end,
containerSize,
curvature = 0.3
}: ConnectionPathParams): string {
const dx = end.x - start.x;
const dy = end.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 计算控制点,创建优雅的弧线
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
// 根据方向调整控制点
let controlX = midX;
let controlY = midY;
// 如果是水平方向较长,控制点偏向垂直方向
if (Math.abs(dx) > Math.abs(dy)) {
controlY = midY + (dy > 0 ? -1 : 1) * distance * curvature;
} else {
// 如果是垂直方向较长,控制点偏向水平方向
controlX = midX + (dx > 0 ? -1 : 1) * distance * curvature;
}
// 确保控制点在容器范围内
controlX = Math.max(10, Math.min(containerSize.width - 10, controlX));
controlY = Math.max(10, Math.min(containerSize.height - 10, controlY));
// 创建二次贝塞尔曲线路径
return `M ${start.x} ${start.y} Q ${controlX} ${controlY} ${end.x} ${end.y}`;
}
/**
* 计算最佳输入框位置
*/
export function calculateInputPosition(
editPointPosition: { x: number; y: number },
containerSize: { width: number; height: number },
inputBoxSize: { width: number; height: number } = { width: 200, height: 80 }
): InputBoxPosition {
const { x: pointX, y: pointY } = editPointPosition;
const { width: containerWidth, height: containerHeight } = containerSize;
const { width: inputWidth, height: inputHeight } = inputBoxSize;
const margin = 20; // 与边界的最小距离
const connectionLength = 80; // 连接线长度
// 计算各个方向的可用空间
const spaceTop = pointY;
const spaceBottom = containerHeight - pointY;
const spaceLeft = pointX;
const spaceRight = containerWidth - pointX;
let direction: 'top' | 'bottom' | 'left' | 'right' = 'right';
let inputX = pointX + connectionLength;
let inputY = pointY - inputHeight / 2;
let connectionEndX = pointX + connectionLength;
let connectionEndY = pointY;
// 优先选择右侧
if (spaceRight >= inputWidth + connectionLength + margin) {
direction = 'right';
inputX = pointX + connectionLength;
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX;
connectionEndY = inputY + inputHeight / 2;
}
// 其次选择左侧
else if (spaceLeft >= inputWidth + connectionLength + margin) {
direction = 'left';
inputX = pointX - connectionLength - inputWidth;
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX + inputWidth;
connectionEndY = inputY + inputHeight / 2;
}
// 然后选择下方
else if (spaceBottom >= inputHeight + connectionLength + margin) {
direction = 'bottom';
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
inputY = pointY + connectionLength;
connectionEndX = inputX + inputWidth / 2;
connectionEndY = inputY;
}
// 最后选择上方
else if (spaceTop >= inputHeight + connectionLength + margin) {
direction = 'top';
inputX = Math.max(margin, Math.min(containerWidth - inputWidth - margin, pointX - inputWidth / 2));
inputY = pointY - connectionLength - inputHeight;
connectionEndX = inputX + inputWidth / 2;
connectionEndY = inputY + inputHeight;
}
// 如果空间不足,强制放在右侧并调整位置
else {
direction = 'right';
inputX = Math.min(containerWidth - inputWidth - margin, pointX + 40);
inputY = Math.max(margin, Math.min(containerHeight - inputHeight - margin, pointY - inputHeight / 2));
connectionEndX = inputX;
connectionEndY = inputY + inputHeight / 2;
}
return {
x: inputX,
y: inputY,
connectionEnd: { x: connectionEndX, y: connectionEndY },
direction
};
}
/**
* 编辑连接线组件
*/
export const EditConnection: React.FC<EditConnectionProps> = ({
startPoint,
endPoint,
containerSize,
style = {},
curvature = 0.3,
animated = true
}) => {
const {
color = 'rgba(255, 255, 255, 0.8)',
strokeWidth = 2,
dashArray = '5,5'
} = style;
// 计算路径
const path = useMemo(() =>
calculateCurvePath({
start: startPoint,
end: endPoint,
containerSize,
curvature
}), [startPoint, endPoint, containerSize, curvature]);
// 计算路径长度用于动画
const pathLength = useMemo(() => {
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
}, [startPoint, endPoint]);
return (
<svg
className="absolute inset-0 pointer-events-none"
width={containerSize.width}
height={containerSize.height}
style={{ zIndex: 10 }}
>
{/* 连接线路径 */}
<motion.path
d={path}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={dashArray}
strokeLinecap="round"
strokeLinejoin="round"
initial={animated ? {
pathLength: 0,
opacity: 0
} : {}}
animate={animated ? {
pathLength: 1,
opacity: 1
} : {}}
transition={animated ? {
pathLength: { duration: 0.6, ease: "easeInOut" },
opacity: { duration: 0.3 }
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2))'
}}
/>
{/* 连接线末端的小圆点 */}
<motion.circle
cx={endPoint.x}
cy={endPoint.y}
r={3}
fill={color}
initial={animated ? {
scale: 0,
opacity: 0
} : {}}
animate={animated ? {
scale: 1,
opacity: 1
} : {}}
transition={animated ? {
delay: 0.4,
duration: 0.3,
type: "spring",
stiffness: 300,
damping: 25
} : {}}
style={{
filter: 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
}}
/>
{/* 动画流动效果(可选) */}
{animated && (
<motion.circle
r={2}
fill="rgba(255, 255, 255, 0.9)"
initial={{ opacity: 0 }}
animate={{
opacity: [0, 1, 0],
offsetDistance: ["0%", "100%"]
}}
transition={{
duration: 2,
repeat: Infinity,
ease: "linear",
delay: 0.6
}}
style={{
offsetPath: `path('${path}')`,
offsetRotate: "0deg"
}}
/>
)}
</svg>
);
};