{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marker-highlight",
  "type": "registry:ui",
  "title": "Marker Highlight",
  "description": "Hand-drawn annotations — marker swipes, underlines, circles, boxes — that draw in on view.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/marker-highlight.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type MarkerType = \"highlight\" | \"underline\" | \"circle\" | \"box\" | \"strike\" | \"bracket\";\nexport type MarkerTrigger = \"view\" | \"hover\" | \"mount\";\n\nexport interface MarkerHighlightProps extends React.HTMLAttributes<HTMLSpanElement> {\n  /** Annotation style. */\n  type?: MarkerType;\n  /** Stroke / marker colour (any CSS colour). */\n  color?: string;\n  /** Stroke width in px (the highlight marker is sized from the line height instead). */\n  strokeWidth?: number;\n  /** Draw time in ms (shared across lines for multiline annotations). */\n  duration?: number;\n  /** Delay before drawing, in ms. */\n  delay?: number;\n  /** What draws the annotation. Hover also responds to keyboard focus within the element. */\n  trigger?: MarkerTrigger;\n  /** Space between the text and the annotation, in px. */\n  padding?: number;\n  /** Seed for the hand-drawn wobble. The same seed always draws the same shape. */\n  seed?: number;\n  /** Annotate each line separately (highlight, underline, strike). Otherwise one box is used. */\n  multiline?: boolean;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/* ------------------------------- geometry --------------------------------- */\n\ninterface Box {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n}\n\ninterface Stroke {\n  d: string;\n  width: number;\n  /** Share of the total length — used to split the draw time across strokes. */\n  length: number;\n}\n\n/** Small deterministic PRNG, so the same seed always produces the same wobble. */\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst f = (n: number) => n.toFixed(2);\n\n/** Catmull-Rom → cubic Bézier through the points, for organic curves. */\nfunction smoothPath(points: [number, number][], closed = false) {\n  if (points.length < 2) return \"\";\n  const p = points;\n  let d = `M${f(p[0][0])},${f(p[0][1])}`;\n  for (let i = 0; i < p.length - 1; i++) {\n    const p0 = p[i - 1] ?? (closed ? p[p.length - 1] : p[i]);\n    const p1 = p[i];\n    const p2 = p[i + 1];\n    const p3 = p[i + 2] ?? p2;\n    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];\n    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];\n    d += `C${f(c1[0])},${f(c1[1])} ${f(c2[0])},${f(c2[1])} ${f(p2[0])},${f(p2[1])}`;\n  }\n  return d;\n}\n\n/** A slightly wavering horizontal stroke from x0 to x1 around y. */\nfunction wobblyLine(rand: () => number, x0: number, x1: number, y: number, amp: number) {\n  const segments = Math.max(2, Math.round((x1 - x0) / 60));\n  const tilt = (rand() - 0.5) * amp * 1.6;\n  const points: [number, number][] = [];\n  for (let i = 0; i <= segments; i++) {\n    const t = i / segments;\n    points.push([x0 + (x1 - x0) * t + (i === 0 || i === segments ? 0 : (rand() - 0.5) * 6), y + tilt * (t - 0.5) + (rand() - 0.5) * amp]);\n  }\n  return smoothPath(points);\n}\n\nfunction union(boxes: Box[]): Box {\n  const x = Math.min(...boxes.map((b) => b.x));\n  const y = Math.min(...boxes.map((b) => b.y));\n  const r = Math.max(...boxes.map((b) => b.x + b.w));\n  const b = Math.max(...boxes.map((b) => b.y + b.h));\n  return { x, y, w: r - x, h: b - y };\n}\n\nfunction buildStrokes(type: MarkerType, lines: Box[], { seed, padding, strokeWidth, multiline }: { seed: number; padding: number; strokeWidth: number; multiline: boolean }): Stroke[] {\n  if (!lines.length) return [];\n  const perLine = multiline && (type === \"highlight\" || type === \"underline\" || type === \"strike\");\n  const boxes = perLine ? lines : [union(lines)];\n\n  return boxes.flatMap((box, i): Stroke[] => {\n    const rand = mulberry32(seed * 9973 + i * 131 + 7);\n    const x0 = box.x - padding;\n    const x1 = box.x + box.w + padding;\n    const y0 = box.y - padding;\n    const y1 = box.y + box.h + padding;\n\n    switch (type) {\n      case \"highlight\": {\n        // A felt-tip swipe: thick, slightly tilted, sized from the line height. Round caps soften the\n        // ends, so the stroke is inset by roughly half its width to keep the overall extent.\n        const width = box.h * 0.66;\n        const inset = width * 0.38;\n        return [{ d: wobblyLine(rand, x0 + inset, x1 - inset, box.y + box.h * 0.56, box.h * 0.09), width, length: x1 - x0 }];\n      }\n      case \"underline\":\n        return [{ d: wobblyLine(rand, x0, x1, box.y + box.h * 0.94 + padding * 0.35, Math.min(4, box.h * 0.08)), width: strokeWidth, length: x1 - x0 }];\n      case \"strike\":\n        return [{ d: wobblyLine(rand, x0, x1, box.y + box.h * 0.56, Math.min(3, box.h * 0.06)), width: strokeWidth, length: x1 - x0 }];\n      case \"box\": {\n        // Four separate strokes with overshoot at the corners, like a quick pen box.\n        const o = () => (rand() - 0.3) * 6;\n        const j = () => (rand() - 0.5) * 3;\n        const top = smoothPath([[x0 + o(), y0 + j()], [(x0 + x1) / 2, y0 + j()], [x1 + o(), y0 + j()]]);\n        const right = smoothPath([[x1 + j(), y0 + o()], [x1 + j(), (y0 + y1) / 2], [x1 + j(), y1 + o()]]);\n        const bottom = smoothPath([[x1 - o(), y1 + j()], [(x0 + x1) / 2, y1 + j()], [x0 - o(), y1 + j()]]);\n        const left = smoothPath([[x0 + j(), y1 - o()], [x0 + j(), (y0 + y1) / 2], [x0 + j(), y0 - o()]]);\n        const w = x1 - x0;\n        const h = y1 - y0;\n        return [\n          { d: top, width: strokeWidth, length: w },\n          { d: right, width: strokeWidth, length: h },\n          { d: bottom, width: strokeWidth, length: w },\n          { d: left, width: strokeWidth, length: h },\n        ];\n      }\n      case \"circle\": {\n        // An ellipse that overshoots its start, with a radius that drifts as it goes round.\n        const cx = (x0 + x1) / 2;\n        const cy = (y0 + y1) / 2;\n        // Slightly larger than the padded box so the ellipse clears the text's corners.\n        const rx = ((x1 - x0) / 2) * 1.06 + 4;\n        const ry = ((y1 - y0) / 2) * 1.32 + 3;\n        const start = Math.PI * (0.9 + rand() * 0.25);\n        const sweep = Math.PI * 2 + 0.35 + rand() * 0.25;\n        const steps = 16;\n        const points: [number, number][] = [];\n        for (let s = 0; s <= steps; s++) {\n          const t = s / steps;\n          const a = start + sweep * t;\n          const drift = 1 + (t - 0.5) * 0.08 + (rand() - 0.5) * 0.035;\n          points.push([cx + Math.cos(a) * rx * drift, cy + Math.sin(a) * ry * drift]);\n        }\n        const length = Math.PI * (rx + ry) * (sweep / (Math.PI * 2));\n        return [{ d: smoothPath(points), width: strokeWidth, length }];\n      }\n      case \"bracket\": {\n        const arm = Math.min(12, (x1 - x0) * 0.2);\n        const j = () => (rand() - 0.5) * 2.5;\n        const left = `M${f(x0 + arm + j())},${f(y0 + j())} L${f(x0 + j())},${f(y0 + j())} L${f(x0 + j())},${f(y1 + j())} L${f(x0 + arm + j())},${f(y1 + j())}`;\n        const right = `M${f(x1 - arm + j())},${f(y0 + j())} L${f(x1 + j())},${f(y0 + j())} L${f(x1 + j())},${f(y1 + j())} L${f(x1 - arm + j())},${f(y1 + j())}`;\n        const length = arm * 2 + (y1 - y0);\n        return [\n          { d: left, width: strokeWidth, length },\n          { d: right, width: strokeWidth, length },\n        ];\n      }\n    }\n  });\n}\n\n/** Reads the element's line boxes relative to the overlay's own origin. */\nfunction measure(root: HTMLElement, overlay: SVGSVGElement): Box[] {\n  const origin = overlay.getBoundingClientRect();\n  return Array.from(root.getClientRects())\n    .filter((r) => r.width > 1 && r.height > 1)\n    .map((r) => ({ x: r.left - origin.left, y: r.top - origin.top, w: r.width, h: r.height }));\n}\n\nfunction sameBoxes(a: Box[], b: Box[]) {\n  return a.length === b.length && a.every((box, i) => Math.abs(box.x - b[i].x) < 0.5 && Math.abs(box.y - b[i].y) < 0.5 && Math.abs(box.w - b[i].w) < 0.5 && Math.abs(box.h - b[i].h) < 0.5);\n}\n\n/**\n * Wraps inline text and draws a hand-made annotation (marker swipe, underline, circle, box,\n * strike-through or brackets) when it scrolls into view, on hover or on mount. Shapes are\n * generated from a seed, so they look hand-drawn but are identical on every render.\n */\nexport function MarkerHighlight({\n  type = \"highlight\",\n  color = \"#FF7448\",\n  strokeWidth = 2,\n  duration = 800,\n  delay = 0,\n  trigger = \"view\",\n  padding = 4,\n  seed = 7,\n  multiline = true,\n  className = \"\",\n  style,\n  children,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: MarkerHighlightProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLSpanElement>(null);\n  const overlayRef = React.useRef<SVGSVGElement>(null);\n  const [lines, setLines] = React.useState<Box[]>([]);\n  const [shown, setShown] = React.useState(false);\n\n  // Measure line boxes after layout, on resize and once web fonts settle (setState only in callbacks).\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const overlay = overlayRef.current;\n    if (!root || !overlay) return;\n    let frame = 0;\n    const update = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(() => {\n        const next = measure(root, overlay);\n        setLines((prev) => (sameBoxes(prev, next) ? prev : next));\n      });\n    };\n    update();\n    // Inline elements never report resizes, so watch the block that contains the text.\n    const observer = new ResizeObserver(update);\n    observer.observe(root.parentElement ?? root);\n    window.addEventListener(\"resize\", update);\n    document.fonts?.ready.then(update).catch(() => {});\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n      window.removeEventListener(\"resize\", update);\n    };\n  }, []);\n\n  const measured = lines.length > 0;\n\n  // Mount and in-view triggers. A frame passes first so the undrawn state is painted.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root || !measured || trigger === \"hover\") return;\n    let frame = 0;\n    const reveal = () => {\n      frame = requestAnimationFrame(() => {\n        frame = requestAnimationFrame(() => setShown(true));\n      });\n    };\n    if (trigger === \"mount\") {\n      reveal();\n      return () => cancelAnimationFrame(frame);\n    }\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) {\n          observer.disconnect();\n          reveal();\n        }\n      },\n      { threshold: 0.6 },\n    );\n    observer.observe(root);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(frame);\n    };\n  }, [trigger, measured]);\n\n  const strokes = buildStrokes(type, lines, { seed, padding, strokeWidth, multiline });\n  const total = strokes.reduce((sum, s) => sum + s.length, 0) || 1;\n  const drawn = reduceMotion || shown;\n  const animate = !reduceMotion;\n\n  // Strokes draw one after another; each gets a share of the duration by its length.\n  const timing: { start: number; share: number }[] = [];\n  for (let i = 0, acc = 0; i < strokes.length; i++) {\n    const share = (strokes[i].length / total) * duration;\n    timing.push({ start: delay + acc, share });\n    acc += share;\n  }\n\n  const engage = (on: boolean) => {\n    if (trigger === \"hover\") setShown(on);\n  };\n\n  return (\n    <span\n      ref={rootRef}\n      className={`relative isolate ${className}`}\n      style={style}\n      onPointerEnter={(event) => {\n        onPointerEnter?.(event);\n        engage(true);\n      }}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event);\n        engage(false);\n      }}\n      onFocus={(event) => {\n        onFocus?.(event);\n        engage(true);\n      }}\n      onBlur={(event) => {\n        onBlur?.(event);\n        engage(false);\n      }}\n      {...props}\n    >\n      {children}\n      <svg\n        ref={overlayRef}\n        aria-hidden=\"true\"\n        focusable=\"false\"\n        width={1}\n        height={1}\n        className=\"pointer-events-none absolute top-0 left-0 -z-10 overflow-visible\"\n      >\n        {strokes.map((stroke, i) => {\n          const { start, share } = timing[i];\n          return (\n            <path\n              key={i}\n              d={stroke.d}\n              pathLength={1}\n              fill=\"none\"\n              stroke={color}\n              strokeWidth={stroke.width}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              className={type === \"highlight\" ? \"opacity-[0.36] dark:opacity-50\" : undefined}\n              style={{\n                // The gap is slightly longer than the path, so no round cap peeks out while undrawn.\n                strokeDasharray: \"1 1.05\",\n                strokeDashoffset: drawn ? 0 : 1.02,\n                transition: animate\n                  ? `stroke-dashoffset ${Math.round(share)}ms cubic-bezier(0.65, 0, 0.35, 1) ${drawn ? Math.round(start) : 0}ms`\n                  : \"none\",\n              }}\n            />\n          );\n        })}\n      </svg>\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/marker-highlight"
}
