import {CSSProperties, ReactElement, useCallback, useEffect, useRef, useState,} from "react" import {angle, middlePos, Pos, relativeTo} from "./Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { startPos: Pos segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void startRadius?: number endRadius?: number style?: ArrowStyle } export interface ArrowStyle { width?: number head?: () => ReactElement tail?: () => ReactElement } const ArrowStyleDefaults = { width: 4, } export interface Segment { next: Pos controlPoint?: Pos } function constraintInCircle(pos: Pos, from: Pos, radius: number): Pos { const theta = angle(pos, from) return { x: pos.x - Math.sin(theta) * radius, y: pos.y - Math.cos(theta) * radius, } } function Triangle({fill}: {fill: string}) { return ( ) } export default function BendableArrow({ startPos, segments, onSegmentsChanges, style, startRadius = 0, endRadius = 0, }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width const [controlPointsDots, setControlPointsDots] = useState( [], ) useEffect(() => { setInternalSegments(segments) }, [segments]); const [internalSegments, setInternalSegments] = useState(segments) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) useEffect(() => { const basePos = containerRef.current!.parentElement!.getBoundingClientRect() setControlPointsDots(computeControlPoints(basePos)) const selectionHandler = (e: MouseEvent) => { if (!(e.target instanceof Node)) return setIsSelected(containerRef.current!.contains(e.target)) } document.addEventListener('mousedown', selectionHandler) return () => document.removeEventListener('mousedown', selectionHandler) }, []); function computeControlPoints(basePos: Pos) { return internalSegments.map(({next, controlPoint}, i) => { const prev = i == 0 ? startPos : internalSegments[i - 1].next const prevRelative = relativeTo(prev, basePos) const nextRelative = relativeTo(next, basePos) const cpPos = controlPoint ? relativeTo(controlPoint, basePos) : { x: prevRelative.x / 2 + nextRelative.x / 2, y: prevRelative.y / 2 + nextRelative.y / 2, } return ( { const segment = internalSegments[i] const segments = internalSegments.toSpliced(i, 1, {...segment, controlPoint}) onSegmentsChanges(segments) }} onMoves={(controlPoint) => { setInternalSegments(is => { return is.toSpliced(i, 1, {...is[i], controlPoint}) }) }} /> ) }) } const update = useCallback(() => { // only one segment is supported for now, which is the first. // any other segments will be ignored const segment = internalSegments[0] ?? null if (segment == null) throw new Error("segments might not be empty.") const endPos = segment.next const basePos = containerRef.current!.parentElement!.getBoundingClientRect() const startRelative = relativeTo(startPos, basePos) const endRelative = relativeTo(endPos!, basePos) const controlPoint = segment.controlPoint ? relativeTo(segment.controlPoint, basePos) : { x: startRelative.x / 2 + endRelative.x / 2, y: startRelative.y / 2 + endRelative.y / 2, } const tailPos = constraintInCircle( startRelative, controlPoint, startRadius!, ) const headPos = constraintInCircle( endRelative, controlPoint, endRadius!, ) const left = Math.min(tailPos.x, headPos.x) const top = Math.min(tailPos.y, headPos.y) Object.assign(tailRef.current!.style, { left: tailPos.x + "px", top: tailPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${-angle(tailPos, controlPoint) * (180 / Math.PI)}deg)` } as CSSProperties) Object.assign(headRef.current!.style, { left: headPos.x + "px", top: headPos.y + "px", transformOrigin: "top center", transform: `translateX(-50%) rotate(${-angle(headPos, controlPoint) * (180 / Math.PI)}deg)` } as CSSProperties) const svgStyle: CSSProperties = { left: left + "px", top: top + "px", } const segmentsRelatives = internalSegments.map(({next, controlPoint}) => { return { next: relativeTo(next, basePos), cp: controlPoint ? relativeTo(controlPoint, basePos) : undefined } }) const computedSegments = segmentsRelatives .map(({next: n, cp}, idx) => { let next = n if (idx == internalSegments.length - 1) { //if it is the last element next = constraintInCircle( next, controlPoint, endRadius!, ) } if (cp == undefined) { return `L${next.x - left} ${next.y - top}` } return `C${cp.x - left} ${cp.y - top}, ${cp.x - left} ${cp.y - top}, ${next.x - left} ${next.y - top}` }) .join(" ") const d = `M${tailPos.x - left} ${tailPos.y - top} ` + computedSegments pathRef.current!.setAttribute("d", d) Object.assign(svgRef.current!.style, svgStyle) if (isSelected) { setControlPointsDots(computeControlPoints(basePos)) } }, [startPos, internalSegments]) useEffect(update, [update]) return (
{style?.head?.call(style) ?? }
{style?.tail?.call(style) ?? }
{isSelected && controlPointsDots}
) } interface ControlPointProps { pos: Pos basePos: Pos, onMoves: (currentPos: Pos) => void onPosValidated: (newPos: Pos) => void, radius?: number } function ControlPoint({ pos, onMoves, onPosValidated, radius = 7, }: ControlPointProps) { const ref = useRef(null) return ( { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onPosValidated(pointPos) }} onDrag={() => { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(pointPos) }} position={{x: pos.x - radius, y: pos.y - radius}} >
) }