2025-09-22 20:05:15 +08:00

310 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 编辑连接线组件
* 实现从编辑点到输入框的弧线连接
*/
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>
);
};