forked from 77media/video-flow
251 lines
7.7 KiB
TypeScript
251 lines
7.7 KiB
TypeScript
/**
|
||
* 编辑连接线组件
|
||
* 实现从编辑点到输入框的弧线连接
|
||
*/
|
||
|
||
import React, { useMemo } from 'react';
|
||
import { motion } from 'framer-motion';
|
||
import { ConnectionPathParams, InputBoxPosition } from './types';
|
||
import {
|
||
CONNECTION_STYLE,
|
||
ARROW_GEOMETRY,
|
||
calculateArrowGeometry,
|
||
calculateCurvePath as calculateUnifiedCurvePath,
|
||
getConnectionAnimationConfig
|
||
} from './connection-config';
|
||
|
||
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 - 8; // 向内偏移8px,指向输入框内部
|
||
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 + 8; // 向内偏移8px,指向输入框内部
|
||
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 - 8; // 向内偏移8px,指向输入框内部
|
||
}
|
||
// 最后选择上方
|
||
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 + 8; // 向内偏移8px,指向输入框内部
|
||
}
|
||
// 如果空间不足,强制放在右侧并调整位置
|
||
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 - 8; // 向内偏移8px,指向输入框内部
|
||
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 = CONNECTION_STYLE.color,
|
||
strokeWidth = CONNECTION_STYLE.strokeWidth,
|
||
dashArray = CONNECTION_STYLE.dashArray
|
||
} = style;
|
||
|
||
// 使用统一的箭头几何计算
|
||
const arrowGeometry = useMemo(() =>
|
||
calculateArrowGeometry(startPoint, endPoint),
|
||
[startPoint, endPoint]
|
||
);
|
||
|
||
// 使用统一的路径计算
|
||
const path = useMemo(() =>
|
||
calculateUnifiedCurvePath(startPoint, arrowGeometry.center, containerSize),
|
||
[startPoint, arrowGeometry.center, containerSize]
|
||
);
|
||
|
||
// 获取统一的动画配置
|
||
const animationConfig = useMemo(() =>
|
||
getConnectionAnimationConfig(animated),
|
||
[animated]
|
||
);
|
||
|
||
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={animationConfig.line.initial}
|
||
animate={animationConfig.line.animate}
|
||
transition={animationConfig.line.transition}
|
||
style={{
|
||
filter: CONNECTION_STYLE.dropShadow
|
||
}}
|
||
/>
|
||
|
||
{/* 几何精确的箭头 - 与连接线完美对齐 */}
|
||
<motion.polygon
|
||
points={arrowGeometry.points.map(p => `${p.x},${p.y}`).join(' ')}
|
||
fill={color}
|
||
initial={animationConfig.arrow.initial}
|
||
animate={animationConfig.arrow.animate}
|
||
transition={animationConfig.arrow.transition}
|
||
style={{
|
||
filter: CONNECTION_STYLE.dropShadow
|
||
}}
|
||
/>
|
||
|
||
{/* 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>
|
||
);
|
||
};
|