{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "callout-arrow", "title": "CalloutArrow", "description": "Hand-drawn curved arrow connecting two DOM elements via refs. Uses common-ancestor positioning — zero scroll listeners, zero redraws on scroll.", "dependencies": [ "roughjs" ], "registryDependencies": [ "https://www.bydefaulthuman.fun/r/rough-lib.json", "https://www.bydefaulthuman.fun/r/crumble-theme.json", "utils" ], "files": [ { "path": "registry/new-york/ui/callout-arrow.tsx", "content": "\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useRef,\n type CSSProperties,\n type ReactNode,\n type RefObject,\n} from \"react\";\nimport { useRough } from \"@/hooks/use-rough\";\nimport { cn } from \"@/lib/utils\";\nimport { stableSeed, type CrumbleTheme } from \"@/lib/rough\";\n\nexport interface CalloutArrowProps {\n className?: string;\n color?: string;\n curvature?: number;\n duration?: number;\n fromRef: RefObject;\n id?: string;\n label?: ReactNode;\n style?: CSSProperties;\n theme?: CrumbleTheme;\n toRef: RefObject;\n withHead?: boolean;\n}\n\nfunction ensureKeyframes() {\n if (typeof window === \"undefined\") return;\n if ((window as any).__crumble_ann_kf) return;\n const s = document.createElement(\"style\");\n s.textContent = `@keyframes crumble-annotation-dash { to { stroke-dashoffset: 0; } }`;\n document.head.appendChild(s);\n (window as any).__crumble_ann_kf = true;\n}\n\nfunction animateGroup(node: Element, duration: number, delay = 0) {\n node.querySelectorAll(\"path\").forEach((path) => {\n const len = path.getTotalLength();\n path.style.strokeDasharray = String(len);\n path.style.strokeDashoffset = String(len);\n path.style.animation = `crumble-annotation-dash ${duration}ms ease-out ${delay}ms forwards`;\n });\n}\n\nfunction getClosestCommonAncestor(a: HTMLElement, b: HTMLElement): HTMLElement {\n const parentsA = new Set();\n let node: HTMLElement | null = a;\n while (node) {\n parentsA.add(node);\n node = node.parentElement;\n }\n let node2: HTMLElement | null = b;\n while (node2) {\n if (parentsA.has(node2)) return node2;\n node2 = node2.parentElement;\n }\n return document.body;\n}\n\nexport function CalloutArrow({\n className,\n color = \"currentColor\",\n curvature = 80,\n duration: durationProp,\n fromRef,\n id,\n label,\n style,\n theme: themeProp,\n toRef,\n withHead = true,\n}: CalloutArrowProps) {\n const externalSvgRef = useRef(null);\n const labelRef = useRef(null);\n const ancestorRef = useRef(null);\n const restoredPositionRef = useRef(null); // ← track original position\n const lastDrawKey = useRef(\"\"); // ← cache key to skip redundant draws\n\n const stableId = id ?? \"callout-arrow\";\n const baseSeed = stableSeed(stableId);\n const { drawLine, drawPath, svgRef, theme } = useRough({\n variant: \"border\",\n stableId,\n svgRef: externalSvgRef,\n theme: themeProp,\n });\n\n const themeDuration = theme === \"ink\" ? 450 : theme === \"crayon\" ? 750 : 580;\n const duration = durationProp ?? themeDuration;\n const strokeW = theme === \"crayon\" ? 2.5 : theme === \"ink\" ? 2 : 1.5;\n\n const draw = useCallback(() => {\n const svg = svgRef.current;\n const fromEl = fromRef.current;\n const toEl = toRef.current;\n if (!svg || !fromEl || !toEl) return;\n\n ensureKeyframes();\n\n const ancestor = getClosestCommonAncestor(fromEl, toEl);\n\n // ── Batch all DOM reads first ──────────────────────────────────────────\n const base = ancestor.getBoundingClientRect();\n const fr = fromEl.getBoundingClientRect();\n const tr = toEl.getBoundingClientRect();\n\n // Build a cheap cache key — skip redraw if nothing moved\n const drawKey = `${fr.left},${fr.top},${tr.left},${tr.top},${base.width},${base.height}`;\n if (drawKey === lastDrawKey.current) return;\n lastDrawKey.current = drawKey;\n\n // ── DOM writes after all reads ─────────────────────────────────────────\n if (ancestorRef.current !== ancestor) {\n ancestorRef.current = ancestor;\n const existingPosition = window.getComputedStyle(ancestor).position;\n if (existingPosition === \"static\") {\n restoredPositionRef.current = \"\"; // was static, restore to \"\"\n ancestor.style.position = \"relative\";\n } else {\n restoredPositionRef.current = null; // don't touch it\n }\n if (svg.parentElement !== ancestor) ancestor.appendChild(svg);\n if (labelRef.current?.parentElement !== ancestor)\n ancestor.appendChild(labelRef.current!);\n }\n\n const w = base.width;\n const h = base.height;\n svg.setAttribute(\"width\", String(w));\n svg.setAttribute(\"height\", String(h));\n svg.setAttribute(\"viewBox\", `0 0 ${w} ${h}`);\n svg.replaceChildren();\n\n // Coords\n const x1 = fr.left - base.left + fr.width / 2;\n const y1 = fr.top - base.top + fr.height / 2;\n const x2 = tr.left - base.left + tr.width / 2;\n const y2 = tr.top - base.top + tr.height / 2;\n\n const dx = x2 - x1;\n const dy = y2 - y1;\n const mag = Math.sqrt(dx * dx + dy * dy) || 1;\n const cpx = (x1 + x2) / 2 - (dy / mag) * curvature;\n const cpy = (y1 + y2) / 2 + (dx / mag) * curvature;\n\n const curve = drawPath(`M ${x1} ${y1} Q ${cpx} ${cpy} ${x2} ${y2}`, {\n fill: \"none\",\n seed: baseSeed,\n stroke: color,\n strokeWidth: strokeW,\n }) as SVGGElement | null;\n if (!curve) return;\n svg.appendChild(curve);\n animateGroup(curve, duration * 0.8);\n\n if (withHead) {\n const tx = x2 - cpx;\n const ty = y2 - cpy;\n const tl = Math.sqrt(tx * tx + ty * ty) || 1;\n const angle = Math.atan2(ty / tl, tx / tl);\n const hs = theme === \"crayon\" ? 14 : 11;\n\n const h1 = drawLine(\n x2,\n y2,\n x2 - Math.cos(angle - 0.48) * hs,\n y2 - Math.sin(angle - 0.48) * hs,\n { seed: baseSeed + 1, stroke: color, strokeWidth: strokeW },\n ) as SVGGElement | null;\n const h2 = drawLine(\n x2,\n y2,\n x2 - Math.cos(angle + 0.48) * hs,\n y2 - Math.sin(angle + 0.48) * hs,\n { seed: baseSeed + 2, stroke: color, strokeWidth: strokeW },\n ) as SVGGElement | null;\n\n if (h1) {\n svg.appendChild(h1);\n animateGroup(h1, duration * 0.15, duration * 0.8);\n }\n if (h2) {\n svg.appendChild(h2);\n animateGroup(h2, duration * 0.15, duration * 0.8);\n }\n }\n\n if (labelRef.current) {\n labelRef.current.style.left = `${x1}px`;\n labelRef.current.style.top = `${y1 - 14}px`;\n }\n }, [\n color,\n curvature,\n baseSeed,\n duration,\n drawLine,\n drawPath,\n fromRef,\n strokeW,\n theme,\n toRef,\n withHead,\n ]);\n\n // ── ResizeObserver instead of window resize ────────────────────────────\n useEffect(() => {\n const raf = requestAnimationFrame(() => draw());\n return () => cancelAnimationFrame(raf);\n }, [draw]);\n\n useEffect(() => {\n // Observe both elements for size/position changes — much cheaper than window resize\n const ro = new ResizeObserver(() => {\n requestAnimationFrame(() => draw());\n });\n if (fromRef.current) ro.observe(fromRef.current);\n if (toRef.current) ro.observe(toRef.current);\n // Also observe ancestor container if available\n if (ancestorRef.current) ro.observe(ancestorRef.current);\n return () => ro.disconnect();\n }, [draw, fromRef, toRef]);\n\n // ── Cleanup: restore ancestor styles ──────────────────────────────────\n useEffect(() => {\n return () => {\n svgRef.current?.remove();\n labelRef.current?.remove();\n if (restoredPositionRef.current !== null && ancestorRef.current) {\n ancestorRef.current.style.position = restoredPositionRef.current;\n }\n };\n }, []);\n\n return (\n <>\n \n {label ? (\n \n {label}\n \n ) : null}\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/callout-arrow.tsx" } ], "type": "registry:ui" }