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) => 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 = ({ element, isSelected, onSelect, onChange }) => { const arrowRef = useRef(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) => { const newPoints = [...points]; newPoints[index] = { x: e.target.x(), y: e.target.y(), }; onChange({ points: newPoints }); }; const handleControlPointDrag = (index: number, e: Konva.KonvaEventObject) => { 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 ( { 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 && ( )} {/* For curved arrows, use Line with many points */} {!isDegenerate && props.style === 'curved' ? ( ) : ( !isDegenerate && ( ) )} {/* Arrow head for curved arrows (drawn separately) */} {!isDegenerate && props.style === 'curved' && props.head !== 'none' && ( )} {/* Label text */} {props.label && ( )} {/* Control points when selected (for curved arrows) */} {isSelected && props.style === 'curved' && ( <> {/* Control point lines */} {controlPoints.map((cp, index) => ( {index === controlPoints.length - 1 && ( )} ))} {/* Control point handles */} {controlPoints.map((cp, index) => ( 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 */} 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 */} 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 }); }} /> )} ); }; export default Arrow;