forked from 77media/video-flow
310 lines
9.0 KiB
TypeScript
310 lines
9.0 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: 300, height: 50 }
|
||
): 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.9)', // White color to match the reference image
|
||
strokeWidth = 2,
|
||
dashArray = '8,4' // Dashed line to match the reference image
|
||
} = style;
|
||
|
||
// 计算箭头几何参数
|
||
const arrowSize = 8;
|
||
const arrowHalfHeight = 4;
|
||
|
||
// 计算连接方向和角度
|
||
const connectionVector = useMemo(() => {
|
||
const dx = endPoint.x - startPoint.x;
|
||
const dy = endPoint.y - startPoint.y;
|
||
const length = Math.sqrt(dx * dx + dy * dy);
|
||
return {
|
||
dx: dx / length,
|
||
dy: dy / length,
|
||
angle: Math.atan2(dy, dx)
|
||
};
|
||
}, [startPoint, endPoint]);
|
||
|
||
// 计算箭头的正确位置和线条终点
|
||
const arrowGeometry = useMemo(() => {
|
||
const { dx, dy, angle } = connectionVector;
|
||
|
||
// 箭头尖端位置(原endPoint)
|
||
const arrowTip = { x: endPoint.x, y: endPoint.y };
|
||
|
||
// 箭头底部中心点(线条应该连接到这里)
|
||
const arrowBase = {
|
||
x: endPoint.x - dx * arrowSize,
|
||
y: endPoint.y - dy * arrowSize
|
||
};
|
||
|
||
// 计算箭头三角形的三个顶点
|
||
const perpX = -dy; // 垂直向量X
|
||
const perpY = dx; // 垂直向量Y
|
||
|
||
const arrowPoints = [
|
||
arrowTip, // 尖端
|
||
{
|
||
x: arrowBase.x + perpX * arrowHalfHeight,
|
||
y: arrowBase.y + perpY * arrowHalfHeight
|
||
},
|
||
{
|
||
x: arrowBase.x - perpX * arrowHalfHeight,
|
||
y: arrowBase.y - perpY * arrowHalfHeight
|
||
}
|
||
];
|
||
|
||
return {
|
||
tip: arrowTip,
|
||
base: arrowBase,
|
||
points: arrowPoints,
|
||
angle
|
||
};
|
||
}, [endPoint, connectionVector, arrowSize, arrowHalfHeight]);
|
||
|
||
// 计算路径(线条终止于箭头底部中心)
|
||
const path = useMemo(() =>
|
||
calculateCurvePath({
|
||
start: startPoint,
|
||
end: arrowGeometry.base, // 连接到箭头底部中心而不是尖端
|
||
containerSize,
|
||
curvature
|
||
}), [startPoint, arrowGeometry.base, containerSize, curvature]);
|
||
|
||
// 计算路径长度用于动画
|
||
const pathLength = useMemo(() => {
|
||
const dx = arrowGeometry.base.x - startPoint.x;
|
||
const dy = arrowGeometry.base.y - startPoint.y;
|
||
return Math.sqrt(dx * dx + dy * dy) * 1.2; // 弧线比直线长约20%
|
||
}, [startPoint, arrowGeometry.base]);
|
||
|
||
return (
|
||
<svg
|
||
className="absolute inset-0 pointer-events-none"
|
||
width={containerSize.width}
|
||
height={containerSize.height}
|
||
style={{ zIndex: 10 }}
|
||
>
|
||
{/* Curved dashed line - properly aligned to arrow base center */}
|
||
<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(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||
}}
|
||
/>
|
||
|
||
{/* Properly aligned arrow head with geometric precision */}
|
||
<motion.polygon
|
||
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
|
||
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(2px 2px 4px rgba(0, 0, 0, 0.8))'
|
||
}}
|
||
/>
|
||
|
||
{/* Debug visualization (remove in production) */}
|
||
{process.env.NODE_ENV === 'development' && (
|
||
<>
|
||
{/* Arrow base center point */}
|
||
<circle
|
||
cx={arrowGeometry.base.x}
|
||
cy={arrowGeometry.base.y}
|
||
r={1}
|
||
fill="red"
|
||
opacity={0.5}
|
||
/>
|
||
{/* Arrow tip point */}
|
||
<circle
|
||
cx={arrowGeometry.tip.x}
|
||
cy={arrowGeometry.tip.y}
|
||
r={1}
|
||
fill="blue"
|
||
opacity={0.5}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
</svg>
|
||
);
|
||
};
|