forked from 77media/video-flow
259 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
};
|