import { CSSProperties, ReactElement, RefObject, useCallback, useEffect, useRef, useState, } from "react" import { add, angle, middlePos, Pos, posWithinBase, ratioWithinBase, } from "./Pos" import "../../style/bendable_arrows.css" import Draggable from "react-draggable" export interface BendableArrowProps { area: RefObject startPos: Pos segments: Segment[] onSegmentsChanges: (edges: Segment[]) => void startRadius?: number endRadius?: number onDeleteRequested?: () => void 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({ area, startPos, segments, onSegmentsChanges, style, startRadius = 0, endRadius = 0, onDeleteRequested = () => {}, }: BendableArrowProps) { const containerRef = useRef(null) const svgRef = useRef(null) const pathRef = useRef(null) const styleWidth = style?.width ?? ArrowStyleDefaults.width useEffect(() => { setInternalSegments(segments) }, [segments]) const [internalSegments, setInternalSegments] = useState(segments) const [isSelected, setIsSelected] = useState(false) const headRef = useRef(null) const tailRef = useRef(null) function computeControlPoints(parentBase: DOMRect) { return segments.map(({ next, controlPoint }, i) => { const prev = i == 0 ? startPos : segments[i - 1].next const prevRelative = posWithinBase(prev, parentBase) const nextRelative = posWithinBase(next, parentBase) const cpPos = controlPoint || ratioWithinBase( add( { x: prevRelative.x / 2 + nextRelative.x / 2, y: prevRelative.y / 2 + nextRelative.y / 2, }, parentBase, ), parentBase, ) return ( { const segment = segments[i] const newSegments = segments.toSpliced(i, 1, { ...segment, controlPoint, }) onSegmentsChanges(newSegments) }} onMoves={(controlPoint) => { setInternalSegments((is) => { return is.toSpliced(i, 1, { ...is[i], controlPoint, }) }) }} /> ) }) } const update = useCallback(() => { const parentBase = area.current!.getBoundingClientRect() // 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 startRelative = posWithinBase(startPos, parentBase) const endRelative = posWithinBase(endPos, parentBase) const controlPoint = segment.controlPoint ? posWithinBase(segment.controlPoint, parentBase) : { 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: posWithinBase(next, parentBase), cp: controlPoint ? posWithinBase(controlPoint, parentBase) : { x: startRelative.x / 2 + endRelative.x / 2, y: startRelative.y / 2 + endRelative.y / 2, }, } }, ) 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, cp, endRadius!) } 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) }, [startPos, internalSegments]) useEffect(update, [update]) useEffect(() => { const selectionHandler = (e: MouseEvent) => { if (!(e.target instanceof Node)) return const isSelected = containerRef.current!.contains(e.target) setIsSelected(isSelected) } document.addEventListener("mousedown", selectionHandler) window.addEventListener("resize", update) return () => { document.removeEventListener("mousedown", selectionHandler) window.removeEventListener("resize", update) } }, [update, containerRef]) return (
{ if (e.key == "Delete") onDeleteRequested() }} />
{style?.head?.call(style) ?? }
{style?.tail?.call(style) ?? }
{isSelected && computeControlPoints(area.current!.getBoundingClientRect())}
) } interface ControlPointProps { posRatio: Pos parentBase: DOMRect onMoves: (currentPos: Pos) => void onPosValidated: (newPos: Pos | undefined) => void radius?: number } function ControlPoint({ posRatio, parentBase, onMoves, onPosValidated, radius = 7, }: ControlPointProps) { const ref = useRef(null) const pos = posWithinBase(posRatio, parentBase) return ( { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onPosValidated(ratioWithinBase(pointPos, parentBase)) }} onDrag={() => { const pointPos = middlePos(ref.current!.getBoundingClientRect()) onMoves(ratioWithinBase(pointPos, parentBase)) }} position={{ x: pos.x - radius, y: pos.y - radius }}>
{ if (e.key == "Delete") { onPosValidated(undefined) } }} tabIndex={0} /> ) }