404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
import React, { useRef, useMemo } from 'react';
|
|
import { Group, Arrow as KonvaArrow, Circle, Line, Text } from 'react-konva';
|
|
import type Konva from 'konva';
|
|
import type { ArrowElement } from '../../types';
|
|
|
|
interface ArrowProps {
|
|
element: ArrowElement;
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
onChange: (updates: Partial<ArrowElement>) => void;
|
|
}
|
|
|
|
// Calculate bezier curve points for smooth curved arrows
|
|
const getBezierPoints = (
|
|
start: { x: number; y: number },
|
|
end: { x: number; y: number },
|
|
controlPoints: { x: number; y: number }[] = []
|
|
): number[] => {
|
|
const numSegments = 50;
|
|
const points: number[] = [];
|
|
|
|
if (controlPoints.length === 0) {
|
|
// No control points, use auto-calculated curve
|
|
const midX = (start.x + end.x) / 2;
|
|
const midY = (start.y + end.y) / 2;
|
|
const dx = end.x - start.x;
|
|
const dy = end.y - start.y;
|
|
// Perpendicular offset for natural curve
|
|
const cp = {
|
|
x: midX - dy * 0.3,
|
|
y: midY + dx * 0.3,
|
|
};
|
|
controlPoints = [cp];
|
|
}
|
|
|
|
if (controlPoints.length === 1) {
|
|
// Quadratic bezier
|
|
const cp = controlPoints[0];
|
|
for (let t = 0; t <= 1; t += 1 / numSegments) {
|
|
const x = Math.pow(1 - t, 2) * start.x + 2 * (1 - t) * t * cp.x + Math.pow(t, 2) * end.x;
|
|
const y = Math.pow(1 - t, 2) * start.y + 2 * (1 - t) * t * cp.y + Math.pow(t, 2) * end.y;
|
|
points.push(x, y);
|
|
}
|
|
} else if (controlPoints.length >= 2) {
|
|
// Cubic bezier
|
|
const cp1 = controlPoints[0];
|
|
const cp2 = controlPoints[1];
|
|
for (let t = 0; t <= 1; t += 1 / numSegments) {
|
|
const x = Math.pow(1 - t, 3) * start.x + 3 * Math.pow(1 - t, 2) * t * cp1.x + 3 * (1 - t) * Math.pow(t, 2) * cp2.x + Math.pow(t, 3) * end.x;
|
|
const y = Math.pow(1 - t, 3) * start.y + 3 * Math.pow(1 - t, 2) * t * cp1.y + 3 * (1 - t) * Math.pow(t, 2) * cp2.y + Math.pow(t, 3) * end.y;
|
|
points.push(x, y);
|
|
}
|
|
}
|
|
|
|
return points;
|
|
};
|
|
|
|
// Get point along bezier curve at parameter t (0-1)
|
|
const getPointOnBezier = (
|
|
start: { x: number; y: number },
|
|
end: { x: number; y: number },
|
|
controlPoints: { x: number; y: number }[],
|
|
t: number
|
|
): { x: number; y: number } => {
|
|
if (controlPoints.length === 1) {
|
|
const cp = controlPoints[0];
|
|
return {
|
|
x: Math.pow(1 - t, 2) * start.x + 2 * (1 - t) * t * cp.x + Math.pow(t, 2) * end.x,
|
|
y: Math.pow(1 - t, 2) * start.y + 2 * (1 - t) * t * cp.y + Math.pow(t, 2) * end.y,
|
|
};
|
|
} else if (controlPoints.length >= 2) {
|
|
const cp1 = controlPoints[0];
|
|
const cp2 = controlPoints[1];
|
|
return {
|
|
x: Math.pow(1 - t, 3) * start.x + 3 * Math.pow(1 - t, 2) * t * cp1.x + 3 * (1 - t) * Math.pow(t, 2) * cp2.x + Math.pow(t, 3) * end.x,
|
|
y: Math.pow(1 - t, 3) * start.y + 3 * Math.pow(1 - t, 2) * t * cp1.y + 3 * (1 - t) * Math.pow(t, 2) * cp2.y + Math.pow(t, 3) * end.y,
|
|
};
|
|
}
|
|
// Linear
|
|
return {
|
|
x: start.x + (end.x - start.x) * t,
|
|
y: start.y + (end.y - start.y) * t,
|
|
};
|
|
};
|
|
|
|
const Arrow: React.FC<ArrowProps> = ({ element, isSelected, onSelect, onChange }) => {
|
|
const arrowRef = useRef<Konva.Arrow>(null);
|
|
const { points, props } = element;
|
|
const start = points[0];
|
|
const end = points[points.length - 1];
|
|
|
|
const hasValidEndpoints =
|
|
!!start &&
|
|
!!end &&
|
|
Number.isFinite(start.x) &&
|
|
Number.isFinite(start.y) &&
|
|
Number.isFinite(end.x) &&
|
|
Number.isFinite(end.y);
|
|
|
|
const dx = hasValidEndpoints ? end.x - start.x : 0;
|
|
const dy = hasValidEndpoints ? end.y - start.y : 0;
|
|
const isDegenerate = !hasValidEndpoints || Math.hypot(dx, dy) < 0.5;
|
|
|
|
// Konva can throw when drawing shadows for 0x0 bounds (e.g. when start/end overlap).
|
|
// Use a tiny, non-zero end point for rendering only.
|
|
const renderStart = hasValidEndpoints ? start : { x: 0, y: 0 };
|
|
const renderEnd = hasValidEndpoints
|
|
? (isDegenerate ? { x: start.x + 1, y: start.y + 1 } : end)
|
|
: { x: 1, y: 1 };
|
|
|
|
// Get control points (either from props or auto-generate for curved)
|
|
const controlPoints = useMemo(() => {
|
|
if (props.style === 'curved') {
|
|
if (props.controlPoints && props.controlPoints.length > 0) {
|
|
return props.controlPoints;
|
|
}
|
|
// Auto-generate a control point
|
|
const midX = (renderStart.x + renderEnd.x) / 2;
|
|
const midY = (renderStart.y + renderEnd.y) / 2;
|
|
const dx = renderEnd.x - renderStart.x;
|
|
const dy = renderEnd.y - renderStart.y;
|
|
return [{
|
|
x: midX - dy * 0.3,
|
|
y: midY + dx * 0.3,
|
|
}];
|
|
}
|
|
return [];
|
|
}, [props.style, props.controlPoints, renderStart.x, renderStart.y, renderEnd.x, renderEnd.y]);
|
|
|
|
// Calculate points for rendering
|
|
const flatPoints = useMemo(() => {
|
|
if (props.style === 'curved') {
|
|
return getBezierPoints(renderStart, renderEnd, controlPoints);
|
|
}
|
|
return points.flatMap(p => [p.x, p.y]);
|
|
}, [props.style, points, renderStart, renderEnd, controlPoints]);
|
|
|
|
const handlePointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => {
|
|
const newPoints = [...points];
|
|
newPoints[index] = {
|
|
x: e.target.x(),
|
|
y: e.target.y(),
|
|
};
|
|
onChange({ points: newPoints });
|
|
};
|
|
|
|
const handleControlPointDrag = (index: number, e: Konva.KonvaEventObject<DragEvent>) => {
|
|
const newControlPoints = [...controlPoints];
|
|
newControlPoints[index] = {
|
|
x: e.target.x(),
|
|
y: e.target.y(),
|
|
};
|
|
onChange({ props: { ...props, controlPoints: newControlPoints } });
|
|
};
|
|
|
|
// Modern arrow head calculations
|
|
const pointerLength = props.head === 'none' ? 0 : Math.max(props.thickness * 3, 12);
|
|
const pointerWidth = props.head === 'none' ? 0 : Math.max(props.thickness * 2.5, 12);
|
|
|
|
// Calculate label position
|
|
const labelPosition = props.labelPosition ?? 0.5;
|
|
const labelPoint = props.style === 'curved'
|
|
? getPointOnBezier(renderStart, renderEnd, controlPoints, labelPosition)
|
|
: { x: renderStart.x + (renderEnd.x - renderStart.x) * labelPosition, y: renderStart.y + (renderEnd.y - renderStart.y) * labelPosition };
|
|
|
|
return (
|
|
<Group
|
|
draggable={!element.locked}
|
|
onDragEnd={(e) => {
|
|
if (e.target !== e.currentTarget) return;
|
|
|
|
const node = e.target;
|
|
const dx = node.x();
|
|
const dy = node.y();
|
|
|
|
const newPoints = points.map((p) => ({
|
|
x: p.x + dx,
|
|
y: p.y + dy,
|
|
}));
|
|
|
|
let newControlPoints = undefined;
|
|
if (props.controlPoints && props.controlPoints.length > 0) {
|
|
newControlPoints = props.controlPoints.map((p) => ({
|
|
x: p.x + dx,
|
|
y: p.y + dy,
|
|
}));
|
|
}
|
|
|
|
// Reset relative position
|
|
node.x(0);
|
|
node.y(0);
|
|
|
|
onChange({
|
|
points: newPoints,
|
|
props: {
|
|
...props,
|
|
controlPoints: newControlPoints || props.controlPoints,
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
{/* If endpoints overlap/invalid, render a small dot instead of a shadowed arrow to avoid Konva draw crashes. */}
|
|
{isDegenerate && (
|
|
<Circle
|
|
x={renderStart.x}
|
|
y={renderStart.y}
|
|
radius={Math.max(6, props.thickness * 2)}
|
|
fill={props.color}
|
|
opacity={0.8}
|
|
onClick={onSelect}
|
|
onTap={onSelect}
|
|
hitStrokeWidth={20}
|
|
/>
|
|
)}
|
|
|
|
{/* For curved arrows, use Line with many points */}
|
|
{!isDegenerate && props.style === 'curved' ? (
|
|
<Line
|
|
points={flatPoints}
|
|
stroke={props.color}
|
|
strokeWidth={props.thickness}
|
|
lineCap="round"
|
|
lineJoin="round"
|
|
shadowColor={props.color}
|
|
shadowBlur={8}
|
|
shadowOpacity={0.2}
|
|
shadowOffset={{ x: 0, y: 0 }}
|
|
onClick={onSelect}
|
|
onTap={onSelect}
|
|
hitStrokeWidth={20}
|
|
/>
|
|
) : (
|
|
!isDegenerate && (
|
|
<KonvaArrow
|
|
ref={arrowRef}
|
|
points={flatPoints}
|
|
stroke={props.color}
|
|
strokeWidth={props.thickness}
|
|
fill={props.head === 'filled' ? props.color : 'transparent'}
|
|
pointerLength={pointerLength}
|
|
pointerWidth={pointerWidth}
|
|
lineCap="round"
|
|
lineJoin="round"
|
|
shadowColor={props.color}
|
|
shadowBlur={8}
|
|
shadowOpacity={0.2}
|
|
shadowOffset={{ x: 0, y: 0 }}
|
|
onClick={onSelect}
|
|
onTap={onSelect}
|
|
hitStrokeWidth={20}
|
|
/>
|
|
)
|
|
)}
|
|
|
|
{/* Arrow head for curved arrows (drawn separately) */}
|
|
{!isDegenerate && props.style === 'curved' && props.head !== 'none' && (
|
|
<KonvaArrow
|
|
points={[
|
|
flatPoints[flatPoints.length - 4] ?? renderStart.x,
|
|
flatPoints[flatPoints.length - 3] ?? renderStart.y,
|
|
renderEnd.x,
|
|
renderEnd.y,
|
|
]}
|
|
stroke={props.color}
|
|
strokeWidth={props.thickness}
|
|
fill={props.head === 'filled' ? props.color : 'transparent'}
|
|
pointerLength={pointerLength}
|
|
pointerWidth={pointerWidth}
|
|
lineCap="round"
|
|
lineJoin="round"
|
|
/>
|
|
)}
|
|
|
|
{/* Label text */}
|
|
{props.label && (
|
|
<Text
|
|
x={labelPoint.x}
|
|
y={labelPoint.y - 20}
|
|
text={props.label}
|
|
fontSize={14}
|
|
fill={props.color}
|
|
fontFamily="Inter, sans-serif"
|
|
align="center"
|
|
offsetX={props.label.length * 3.5}
|
|
/>
|
|
)}
|
|
|
|
{/* Control points when selected (for curved arrows) */}
|
|
{isSelected && props.style === 'curved' && (
|
|
<>
|
|
{/* Control point lines */}
|
|
{controlPoints.map((cp, index) => (
|
|
<React.Fragment key={`line-${index}`}>
|
|
<Line
|
|
points={index === 0 ? [start.x, start.y, cp.x, cp.y] : [controlPoints[index - 1].x, controlPoints[index - 1].y, cp.x, cp.y]}
|
|
stroke="#60a5fa"
|
|
strokeWidth={1}
|
|
dash={[4, 4]}
|
|
opacity={0.5}
|
|
/>
|
|
{index === controlPoints.length - 1 && (
|
|
<Line
|
|
points={[cp.x, cp.y, end.x, end.y]}
|
|
stroke="#60a5fa"
|
|
strokeWidth={1}
|
|
dash={[4, 4]}
|
|
opacity={0.5}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
|
|
{/* Control point handles */}
|
|
{controlPoints.map((cp, index) => (
|
|
<Circle
|
|
key={`cp-${index}`}
|
|
x={cp.x}
|
|
y={cp.y}
|
|
radius={6}
|
|
fill="#60a5fa"
|
|
stroke="#ffffff"
|
|
strokeWidth={2}
|
|
shadowColor="rgba(0,0,0,0.3)"
|
|
shadowBlur={4}
|
|
shadowOffset={{ x: 0, y: 1 }}
|
|
draggable={!element.locked}
|
|
onDragMove={(e) => handleControlPointDrag(index, e)}
|
|
onDragEnd={(e) => handleControlPointDrag(index, e)}
|
|
onMouseEnter={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'move';
|
|
e.target.scale({ x: 1.3, y: 1.3 });
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'default';
|
|
e.target.scale({ x: 1, y: 1 });
|
|
}}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{/* Endpoint control points when selected */}
|
|
{isSelected && (
|
|
<>
|
|
{/* Start point */}
|
|
<Circle
|
|
x={start.x}
|
|
y={start.y}
|
|
radius={5}
|
|
fill="#ffffff"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
shadowColor="rgba(0,0,0,0.15)"
|
|
shadowBlur={4}
|
|
shadowOffset={{ x: 0, y: 1 }}
|
|
draggable={!element.locked}
|
|
onDragMove={(e) => handlePointDrag(0, e)}
|
|
onDragEnd={(e) => handlePointDrag(0, e)}
|
|
onMouseEnter={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'grab';
|
|
e.target.scale({ x: 1.5, y: 1.5 });
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'default';
|
|
e.target.scale({ x: 1, y: 1 });
|
|
}}
|
|
/>
|
|
{/* End point */}
|
|
<Circle
|
|
x={end.x}
|
|
y={end.y}
|
|
radius={5}
|
|
fill="#ffffff"
|
|
stroke="#3b82f6"
|
|
strokeWidth={2}
|
|
shadowColor="rgba(0,0,0,0.15)"
|
|
shadowBlur={4}
|
|
shadowOffset={{ x: 0, y: 1 }}
|
|
draggable={!element.locked}
|
|
onDragMove={(e) => handlePointDrag(points.length - 1, e)}
|
|
onDragEnd={(e) => handlePointDrag(points.length - 1, e)}
|
|
onMouseEnter={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'grab';
|
|
e.target.scale({ x: 1.5, y: 1.5 });
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
const container = e.target.getStage()?.container();
|
|
if (container) container.style.cursor = 'default';
|
|
e.target.scale({ x: 1, y: 1 });
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
</Group>
|
|
);
|
|
};
|
|
|
|
export default Arrow;
|