{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "annotation", "title": "Annotation", "description": "Inline rough callout annotation — box, circle, underline, bracket, arrow-label types.", "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/annotation.tsx", "content": "\"use client\";\n\n/**\n * Annotation — Crumble wrapper around rough-notation.\n *\n * rough-notation handles the hard parts: SVG layout timing, getTotalLength(),\n * CSS keyframe injection, multiline via getClientRects(), resize observation,\n * and annotationGroup sequencing. We wrap it in React with Crumble's theme\n * system and add trigger=\"inView\" | \"hover\" | \"mount\".\n *\n * Install dep: npm install rough-notation\n */\n\nimport {\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n type CSSProperties,\n type HTMLAttributes,\n type ReactNode,\n} from \"react\";\nimport { annotate } from \"rough-notation\";\n\ntype RoughAnnotation = ReturnType;\nimport { cn } from \"@/lib/utils\";\nimport {\n CrumbleContext,\n type CrumbleColorProps,\n type CrumbleTheme,\n} from \"@/lib/rough\";\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type AnnotationType =\n | \"underline\"\n | \"box\"\n | \"circle\"\n | \"highlight\"\n | \"strike-through\"\n | \"crossed-off\"\n | \"bracket\";\n\nexport type AnnotationSide = \"top\" | \"bottom\" | \"left\" | \"right\";\nexport type BracketSide = \"left\" | \"right\" | \"top\" | \"bottom\";\nexport type AnnotationTrigger = \"mount\" | \"inView\" | \"hover\";\n\nexport interface AnnotationProps\n extends HTMLAttributes, CrumbleColorProps {\n animate?: boolean;\n /**\n * Delay before the draw animation starts (ms).\n * Useful for sequencing multiple annotations on a page.\n */\n animationDelay?: number;\n animationDuration?: number;\n /** Which bracket sides to draw. Only for type=\"bracket\". Default: [\"left\",\"right\"] */\n brackets?: BracketSide | BracketSide[];\n color?: string;\n /** Number of times each stroke is drawn. Default 2. Creates the double-drawn look. */\n iterations?: number;\n label?: ReactNode;\n labelSide?: AnnotationSide;\n /**\n * Annotate each text line independently (via getClientRects).\n * Essential for underline / highlight / strike-through on wrapping text.\n */\n multiline?: boolean;\n padding?: number | [number, number] | [number, number, number, number];\n /**\n * Controlled visibility. Provide this to take full control.\n * Re-animates every time show flips false → true.\n */\n show?: boolean;\n strokeWidth?: number;\n theme?: CrumbleTheme;\n /**\n * \"mount\" — draw on first render (default)\n * \"inView\" — draw once when element enters viewport\n * \"hover\" — draw on mouseenter, reset on mouseleave\n */\n trigger?: AnnotationTrigger;\n type?: AnnotationType;\n}\n\n// ─── Theme → rough-notation config ───────────────────────────────────────────\n\nfunction getThemeDefaults(theme: CrumbleTheme) {\n switch (theme) {\n case \"ink\":\n return { strokeWidth: 1.8, animationDuration: 380, iterations: 1 };\n case \"crayon\":\n return { strokeWidth: 3, animationDuration: 750, iterations: 2 };\n case \"pencil\":\n default:\n return { strokeWidth: 1.2, animationDuration: 600, iterations: 2 };\n }\n}\n\n// ─── Component ────────────────────────────────────────────────────────────────\n\nexport function Annotation({\n animate = true,\n animationDelay = 0,\n animationDuration,\n brackets: bracketsProp,\n children,\n className,\n color = \"currentColor\",\n iterations: iterationsProp,\n label,\n labelSide = \"top\",\n multiline = false,\n padding = 5,\n show: showProp,\n strokeWidth: strokeWidthProp,\n style,\n theme: themeProp,\n trigger = \"mount\",\n type = \"underline\",\n // CrumbleColorProps — unused by rough-notation directly, accepted for API compat\n fill: _fill,\n stroke: _stroke,\n strokeMuted: _strokeMuted,\n ...props\n}: AnnotationProps) {\n const spanRef = useRef(null);\n const annotationRef = useRef(null);\n const timeoutRef = useRef | null>(null);\n\n const { theme: contextTheme } = useContext(CrumbleContext);\n const theme = themeProp ?? contextTheme;\n const themeDefaults = getThemeDefaults(theme);\n\n const resolvedDuration = animationDuration ?? themeDefaults.animationDuration;\n const resolvedIterations = iterationsProp ?? themeDefaults.iterations;\n const resolvedStrokeWidth = strokeWidthProp ?? themeDefaults.strokeWidth;\n const resolvedBrackets = bracketsProp\n ? Array.isArray(bracketsProp)\n ? bracketsProp\n : [bracketsProp]\n : ([\"left\", \"right\"] as BracketSide[]);\n\n // ── Controlled vs uncontrolled ──────────────────────────────────────────────\n const isControlled = showProp !== undefined;\n const [internalVisible, setInternalVisible] = useState(\n !isControlled && trigger === \"mount\",\n );\n const visible = isControlled ? showProp : internalVisible;\n\n // ── Show / hide helpers ─────────────────────────────────────────────────────\n const showAnnotation = useCallback(() => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n timeoutRef.current = setTimeout(() => {\n annotationRef.current?.show();\n timeoutRef.current = null;\n }, animationDelay);\n }, [animationDelay]);\n\n const hideAnnotation = useCallback(() => {\n if (timeoutRef.current) {\n clearTimeout(timeoutRef.current);\n timeoutRef.current = null;\n }\n annotationRef.current?.hide();\n }, []);\n\n // ── Init rough-notation annotation ─────────────────────────────────────────\n useEffect(() => {\n const el = spanRef.current;\n if (!el) return;\n\n // rough-notation inserts SVG as a sibling to el, so the parent needs\n // position:relative. We set it here rather than requiring the user to.\n const parent = el.parentElement;\n if (parent) {\n const pos = window.getComputedStyle(parent).position;\n if (!pos || pos === \"static\") {\n parent.style.position = \"relative\";\n }\n }\n\n const ann = annotate(el, {\n type,\n animate,\n animationDuration: resolvedDuration,\n color,\n strokeWidth: resolvedStrokeWidth,\n padding,\n iterations: resolvedIterations,\n multiline,\n brackets: resolvedBrackets as any,\n });\n\n annotationRef.current = ann;\n\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current);\n ann.remove();\n annotationRef.current = null;\n };\n // Only re-init if the type changes — type cannot be changed on a live annotation\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [type]);\n\n // ── Live-update mutable props (no re-init needed) ──────────────────────────\n useEffect(() => {\n const ann = annotationRef.current;\n if (!ann) return;\n ann.animate = animate;\n ann.animationDuration = resolvedDuration;\n ann.color = color;\n ann.strokeWidth = resolvedStrokeWidth;\n ann.padding = padding as any;\n ann.iterations = resolvedIterations;\n // Re-show to apply new styles if currently visible\n if (ann.isShowing()) {\n ann.show();\n }\n }, [\n animate,\n resolvedDuration,\n color,\n resolvedStrokeWidth,\n padding,\n resolvedIterations,\n ]);\n\n // ── React to visible state ──────────────────────────────────────────────────\n useEffect(() => {\n if (visible) {\n showAnnotation();\n } else {\n hideAnnotation();\n }\n }, [visible, showAnnotation, hideAnnotation]);\n\n // ── Trigger: inView ─────────────────────────────────────────────────────────\n useEffect(() => {\n if (isControlled || trigger !== \"inView\") return;\n const el = spanRef.current;\n if (!el) return;\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setInternalVisible(true);\n observer.disconnect(); // Draw once, like a real marker\n }\n },\n { threshold: 0.2 },\n );\n observer.observe(el);\n return () => observer.disconnect();\n }, [isControlled, trigger]);\n\n // ── Trigger: hover ──────────────────────────────────────────────────────────\n useEffect(() => {\n if (isControlled || trigger !== \"hover\") return;\n const el = spanRef.current;\n if (!el) return;\n\n const onEnter = () => setInternalVisible(true);\n const onLeave = () => setInternalVisible(false);\n\n el.addEventListener(\"mouseenter\", onEnter);\n el.addEventListener(\"mouseleave\", onLeave);\n return () => {\n el.removeEventListener(\"mouseenter\", onEnter);\n el.removeEventListener(\"mouseleave\", onLeave);\n };\n }, [isControlled, trigger]);\n\n // ── Label positioning ────────────────────────────────────────────────────────\n const labelStyle: Record = {\n top: {\n bottom: \"100%\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n marginBottom: 4,\n },\n bottom: {\n top: \"100%\",\n left: \"50%\",\n transform: \"translateX(-50%)\",\n marginTop: 4,\n },\n left: {\n right: \"100%\",\n top: \"50%\",\n transform: \"translateY(-50%)\",\n marginRight: 8,\n },\n right: {\n left: \"100%\",\n top: \"50%\",\n transform: \"translateY(-50%)\",\n marginLeft: 8,\n },\n };\n\n return (\n \n {children}\n {label ? (\n \n {label}\n \n ) : null}\n \n );\n}\n", "type": "registry:ui", "target": "components/crumble/ui/annotation.tsx" } ], "type": "registry:ui" }