import React, {FC, useCallback, useLayoutEffect, useRef, useState} from "react"; import {motion} from "motion/react"; import {__} from "../globals"; import {cn} from "../utils/cn"; import {autoUpdate, offset, shift, useFloating} from "@floating-ui/react"; import type {Placement} from "@floating-ui/react"; import rough from "roughjs/bin/rough"; type AnchorSide = 'top' | 'right' | 'bottom' | 'left'; type Point = { x: number, y: number } type ArrowGeometry = { path: string, connectorPath: string, headPath: string } type SuggestedArrowProps = { show: boolean, children: React.ReactNode, label?: string, className?: string, labelPlacement?: Placement, startAnchor?: AnchorSide, endAnchor?: AnchorSide } const getAnchorPoint = (rect: { left: number, top: number, width: number, height: number }, anchor: AnchorSide): Point => { if (anchor === 'top') { return {x: rect.left + rect.width / 2, y: rect.top}; } if (anchor === 'right') { return {x: rect.left + rect.width, y: rect.top + rect.height / 2}; } if (anchor === 'left') { return {x: rect.left, y: rect.top + rect.height / 2}; } return {x: rect.left + rect.width / 2, y: rect.top + rect.height}; }; const getArrowGeometry = (start: Point, end: Point): ArrowGeometry => { const dx = end.x - start.x; const dy = end.y - start.y; const absDx = Math.abs(dx); const absDy = Math.max(1, Math.abs(dy)); const direction = dx >= 0 ? 1 : -1; const verticalDirection = dy >= 0 ? 1 : -1; const outwardSwing = Math.max(28, Math.min(76, absDx * 1.15)); const inwardSwing = Math.max(16, Math.min(44, absDx * 0.72)); const control1 = { x: start.x + direction * outwardSwing, y: start.y + Math.max(18, absDy * 0.3) * verticalDirection }; const control2 = { x: end.x - direction * inwardSwing, y: end.y - Math.max(22, absDy * 0.52) * verticalDirection }; const tangentX = end.x - control2.x; const tangentY = end.y - control2.y; const tangentLength = Math.hypot(tangentX, tangentY) || 1; const directionX = tangentX / tangentLength; const directionY = tangentY / tangentLength; const normalX = -directionY; const normalY = directionX; const tipGap = 12; const tip = { x: end.x - directionX * tipGap, y: end.y - directionY * tipGap }; const headLength = 12; const headHalfWidth = 6.5; const shaftEnd = { x: tip.x - directionX * headLength, y: tip.y - directionY * headLength }; const path = `M ${start.x} ${start.y} C ${control1.x} ${control1.y} ${control2.x} ${control2.y} ${shaftEnd.x} ${shaftEnd.y}`; const connectorPath = `M ${shaftEnd.x} ${shaftEnd.y} L ${tip.x} ${tip.y}`; const leftX = shaftEnd.x + normalX * headHalfWidth; const leftY = shaftEnd.y + normalY * headHalfWidth; const rightX = shaftEnd.x - normalX * headHalfWidth; const rightY = shaftEnd.y - normalY * headHalfWidth; const headPath = `M ${leftX} ${leftY} L ${tip.x} ${tip.y} L ${rightX} ${rightY}`; return {path, connectorPath, headPath}; }; export const SuggestedArrow: FC = ({ show, children, label, className, labelPlacement = 'top-start', startAnchor = 'bottom', endAnchor = 'top' }) => { const wrapperRef = useRef(null); const svgRef = useRef(null); const [geometry, setGeometry] = useState(null); const {refs, floatingStyles, x, y} = useFloating({ placement: labelPlacement, middleware: [ offset({ mainAxis: 62, crossAxis: -104 }), shift({padding: 8}) ], whileElementsMounted: autoUpdate }); const updateGeometry = useCallback(() => { if (!wrapperRef.current || !refs.reference.current || !refs.floating.current) { return; } const wrapperRect = wrapperRef.current.getBoundingClientRect(); const sourceRect = refs.floating.current.getBoundingClientRect(); const targetRect = refs.reference.current.getBoundingClientRect(); const relativeSource = { left: sourceRect.left - wrapperRect.left, top: sourceRect.top - wrapperRect.top, width: sourceRect.width, height: sourceRect.height }; const relativeTarget = { left: targetRect.left - wrapperRect.left, top: targetRect.top - wrapperRect.top, width: targetRect.width, height: targetRect.height }; setGeometry( getArrowGeometry( getAnchorPoint(relativeSource, startAnchor), getAnchorPoint(relativeTarget, endAnchor) ) ); }, [endAnchor, refs.floating, refs.reference, startAnchor]); useLayoutEffect(() => { if (x == null || y == null) { return; } updateGeometry(); }, [x, y, updateGeometry]); useLayoutEffect(() => { if (!svgRef.current || !geometry) { return; } const svg = svgRef.current; svg.innerHTML = ''; const sketch = rough.svg(svg); const roughPath = sketch.path(geometry.path, { stroke: '#8E8FF2', strokeWidth: 2.2, roughness: 0.95, bowing: 0.9, preserveVertices: true, disableMultiStroke: true, fill: 'none', }); const connectorPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); connectorPath.setAttribute('d', geometry.connectorPath); connectorPath.setAttribute('fill', 'none'); connectorPath.setAttribute('stroke', '#8E8FF2'); connectorPath.setAttribute('stroke-width', '2.2'); connectorPath.setAttribute('class', 'cp-suggested-arrow-connector'); const headPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); headPath.setAttribute('d', geometry.headPath); headPath.setAttribute('fill', 'none'); headPath.setAttribute('stroke', '#8E8FF2'); headPath.setAttribute('stroke-width', '2.2'); headPath.setAttribute('class', 'cp-suggested-arrow-head'); roughPath.querySelectorAll('path').forEach((path) => { path.classList.add('cp-suggested-arrow-line'); }); svg.appendChild(roughPath); svg.appendChild(connectorPath); svg.appendChild(headPath); }, [geometry]); if (!show) { return <>{children}; } return (
{label || __('Suggested')}
{children}
); };