{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "margin-notes",
  "type": "registry:component",
  "title": "Margin Notes",
  "description": "A reviewer's pen that draws itself onto your text as it scrolls into view: wobbly circles, double underlines, boxes and strike-throughs, then arrows that curve from a handwritten note to the words it means.",
  "categories": [
    "motion",
    "Text"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/margin-notes.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { motion, useInView, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type MarkType = \"circle\" | \"underline\" | \"double\" | \"box\" | \"strike\";\nexport type MarkTone = \"red\" | \"blue\" | \"amber\" | \"green\" | \"ink\";\n\nconst tones: Record<MarkTone, string> = {\n  red: \"text-rose-500 dark:text-rose-400\",\n  blue: \"text-sky-600 dark:text-sky-400\",\n  amber: \"text-amber-500 dark:text-amber-400\",\n  green: \"text-emerald-600 dark:text-emerald-400\",\n  ink: \"text-foreground\",\n};\n\ntype Pt = [number, number];\ntype Box = { x: number; y: number; w: number; h: number };\ntype MarkEntry = { el: HTMLElement; type: MarkType; tone: MarkTone; note?: string };\ntype Stroke = { key: string; d: string; delay: number; duration: number; tone: MarkTone };\ntype Drawing = { strokes: Stroke[]; notes: Record<string, number> };\n\ninterface MarginNotesContext {\n  prefix: string;\n  tone: MarkTone;\n  active: boolean;\n  reduce: boolean;\n  run: number;\n  notes: Record<string, number>;\n  addMark: (id: string, entry: MarkEntry) => () => void;\n  addNote: (id: string, el: HTMLElement) => () => void;\n}\n\nconst Context = createContext<MarginNotesContext | null>(null);\n\nfunction useMarginNotes() {\n  const ctx = useContext(Context);\n  if (!ctx) throw new Error(\"Mark and MarginNote must be used inside MarginNotes\");\n  return ctx;\n}\n\nfunction useCalmMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nfunction seeded(text: string) {\n  let a = 2166136261;\n  for (const c of text) a = Math.imul(a ^ c.charCodeAt(0), 16777619);\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 round = (n: number) => Math.round(n * 10) / 10;\n\nfunction spline(points: Pt[]) {\n  let d = `M${round(points[0][0])} ${round(points[0][1])}`;\n  for (let i = 0; i < points.length - 1; i++) {\n    const p0 = points[i - 1] ?? points[i];\n    const p1 = points[i];\n    const p2 = points[i + 1];\n    const p3 = points[i + 2] ?? p2;\n    const c1: Pt = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];\n    const c2: Pt = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];\n    d += ` C${round(c1[0])} ${round(c1[1])} ${round(c2[0])} ${round(c2[1])} ${round(p2[0])} ${round(p2[1])}`;\n  }\n  return d;\n}\n\nfunction lengthOf(points: Pt[]) {\n  let total = 0;\n  for (let i = 1; i < points.length; i++) total += Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]);\n  return total;\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 right = Math.max(...boxes.map((b) => b.x + b.w));\n  const bottom = Math.max(...boxes.map((b) => b.y + b.h));\n  return { x, y, w: right - x, h: bottom - y };\n}\n\nfunction circle(b: Box, rand: () => number): Pt[] {\n  const cx = b.x + b.w / 2;\n  const cy = b.y + b.h / 2;\n  const rx = b.w / 2 + 5 + rand() * 2;\n  const ry = b.h / 2 + 3 + rand() * 2;\n  const tilt = (rand() - 0.5) * 0.14;\n  const start = -Math.PI * (0.6 + rand() * 0.12);\n  const sweep = Math.PI * 2 * (1.1 + rand() * 0.06);\n  const phase = rand() * Math.PI * 2;\n  const points: Pt[] = [];\n  for (let k = 0; k <= 28; k++) {\n    const t = k / 28;\n    const a = start - sweep * t;\n    const grow = 0.97 + t * 0.07 + Math.sin(a * 2 + phase) * 0.025;\n    const x = Math.cos(a) * rx * grow;\n    const y = Math.sin(a) * ry * grow;\n    points.push([cx + x * Math.cos(tilt) - y * Math.sin(tilt), cy + x * Math.sin(tilt) + y * Math.cos(tilt)]);\n  }\n  return points;\n}\n\nfunction line(b: Box, rand: () => number, at: number, flick: boolean): Pt[] {\n  const x0 = b.x - 1 - rand() * 1.5;\n  const x1 = b.x + b.w + 1 + rand() * 2;\n  const y = b.y + b.h * at;\n  const rise = (rand() - 0.5) * 2.4;\n  const sag = (rand() - 0.3) * 1.6;\n  const steps = Math.max(3, Math.round(b.w / 26));\n  const points: Pt[] = [];\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps;\n    points.push([x0 + (x1 - x0) * t, y + rise * t + Math.sin(t * Math.PI) * sag + (rand() - 0.5) * 0.6]);\n  }\n  if (flick) points.push([x1 + 3, y + rise - 2.5]);\n  return points;\n}\n\nfunction double(b: Box, rand: () => number): Pt[] {\n  const out = line(b, rand, 1, false);\n  const back = line({ ...b, x: b.x + b.w * 0.06, w: b.w * 0.9 }, rand, 1, false)\n    .reverse()\n    .map(([x, y]): Pt => [x, y + 4]);\n  return [...out, ...back];\n}\n\nfunction box(b: Box, rand: () => number): Pt[] {\n  const j = () => (rand() - 0.5) * 3;\n  const tl: Pt = [b.x - 4 + j(), b.y - 2 + j()];\n  const tr: Pt = [b.x + b.w + 4 + j(), b.y - 3 + j()];\n  const br: Pt = [b.x + b.w + 3 + j(), b.y + b.h + 2 + j()];\n  const bl: Pt = [b.x - 3 + j(), b.y + b.h + 3 + j()];\n  const mid = (p: Pt, q: Pt): Pt => [(p[0] + q[0]) / 2 + j() * 0.6, (p[1] + q[1]) / 2 + j() * 0.6];\n  return [tl, mid(tl, tr), tr, tr, mid(tr, br), br, br, mid(br, bl), bl, bl, mid(bl, tl), [tl[0] + 1, tl[1] - 1], [tl[0] + 9, tl[1] - 3]];\n}\n\nfunction distance(a: Box, b: Box) {\n  return Math.hypot(a.x + a.w / 2 - (b.x + b.w / 2), a.y + a.h / 2 - (b.y + b.h / 2));\n}\n\nfunction edge(b: Box, toward: Pt, pad: number): Pt {\n  const cx = b.x + b.w / 2;\n  const cy = b.y + b.h / 2;\n  const dx = toward[0] - cx;\n  const dy = toward[1] - cy;\n  const t = Math.min((b.w / 2 + pad) / Math.abs(dx || 1e-6), (b.h / 2 + pad) / Math.abs(dy || 1e-6));\n  return [cx + dx * t, cy + dy * t];\n}\n\nfunction arrow(from: Box, to: Box, rand: () => number) {\n  const toCenter: Pt = [to.x + to.w / 2, to.y + to.h / 2];\n  const fromCenter: Pt = [from.x + from.w / 2, from.y + from.h / 2];\n  const a = edge(from, toCenter, 6);\n  const b = edge(to, fromCenter, 10);\n  const len = Math.hypot(b[0] - a[0], b[1] - a[1]);\n  const side = b[0] >= a[0] ? -1 : 1;\n  const nx = -(b[1] - a[1]) / len;\n  const ny = (b[0] - a[0]) / len;\n  const bend = len * (0.2 + rand() * 0.08) * side;\n  const control: Pt = [(a[0] + b[0]) / 2 + nx * bend, (a[1] + b[1]) / 2 + ny * bend];\n  const shaft: Pt[] = [];\n  for (let i = 0; i <= 8; i++) {\n    const t = i / 8;\n    const u = 1 - t;\n    shaft.push([u * u * a[0] + 2 * u * t * control[0] + t * t * b[0], u * u * a[1] + 2 * u * t * control[1] + t * t * b[1]]);\n  }\n  const tail = shaft[shaft.length - 2];\n  const angle = Math.atan2(b[1] - tail[1], b[0] - tail[0]);\n  const size = 8;\n  const wing = (offset: number): Pt => [b[0] - Math.cos(angle + offset) * size, b[1] - Math.sin(angle + offset) * size];\n  const head = `M${round(wing(0.5)[0])} ${round(wing(0.5)[1])} L${round(b[0])} ${round(b[1])} L${round(wing(-0.45)[0])} ${round(wing(-0.45)[1])}`;\n  return { shaft: spline(shaft), length: lengthOf(shaft), head };\n}\n\nconst pace = (length: number, min: number, max: number) => Math.min(max, Math.max(min, length / 560));\n\nexport interface MarginNotesProps extends ComponentProps<\"div\"> {\n  tone?: MarkTone;\n  once?: boolean;\n  strokeWidth?: number;\n}\n\nexport function MarginNotes({ tone = \"red\", once = true, strokeWidth = 1.8, className, children, ...props }: MarginNotesProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const prefix = useId();\n  const reduce = useCalmMotion();\n  const inView = useInView(ref, { once, amount: 0.4 });\n  const marks = useRef(new Map<string, MarkEntry>());\n  const noteEls = useRef(new Map<string, HTMLElement>());\n  const [version, setVersion] = useState(0);\n  const [drawing, setDrawing] = useState<Drawing>({ strokes: [], notes: {} });\n  const [run, setRun] = useState(0);\n\n  useEffect(() => {\n    if (inView) setRun((r) => r + 1);\n  }, [inView]);\n\n  const addMark = useCallback((id: string, entry: MarkEntry) => {\n    marks.current.set(id, entry);\n    setVersion((v) => v + 1);\n    return () => {\n      marks.current.delete(id);\n      setVersion((v) => v + 1);\n    };\n  }, []);\n\n  const addNote = useCallback((id: string, el: HTMLElement) => {\n    noteEls.current.set(id, el);\n    setVersion((v) => v + 1);\n    return () => {\n      noteEls.current.delete(id);\n      setVersion((v) => v + 1);\n    };\n  }, []);\n\n  useLayoutEffect(() => {\n    const root = ref.current;\n    if (!root || version < 0) return;\n    let frame = 0;\n    const measure = () => {\n      const frameBox = root.getBoundingClientRect();\n      const scale = frameBox.width / (root.offsetWidth || 1) || 1;\n      const local = (r: DOMRect): Box => ({\n        x: (r.left - frameBox.left) / scale,\n        y: (r.top - frameBox.top) / scale,\n        w: r.width / scale,\n        h: r.height / scale,\n      });\n      const ordered = [...marks.current.entries()].sort(([, a], [, b]) =>\n        a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,\n      );\n      const strokes: Stroke[] = [];\n      const notes: Record<string, number> = {};\n      let clock = 0.1;\n      ordered.forEach(([, mark], index) => {\n        const boxes = [...mark.el.getClientRects()].filter((r) => r.width > 0).map(local);\n        if (!boxes.length) return;\n        const rand = seeded(`${index}-${mark.type}-${mark.el.textContent ?? \"\"}`);\n        const whole = union(boxes);\n        const paths: Pt[][] =\n          mark.type === \"circle\"\n            ? [circle(whole, rand)]\n            : mark.type === \"box\"\n              ? [box(whole, rand)]\n              : mark.type === \"double\"\n                ? boxes.map((b) => double(b, rand))\n                : mark.type === \"strike\"\n                  ? boxes.map((b) => line(b, rand, 0.56, false))\n                  : boxes.map((b, i) => line(b, rand, 1, i === boxes.length - 1));\n        let t = clock;\n        paths.forEach((points, i) => {\n          const duration = pace(lengthOf(points), 0.2, 0.6);\n          strokes.push({ key: `${index}-${i}`, d: spline(points), delay: t, duration, tone: mark.tone });\n          t += duration * 0.92;\n        });\n        const noteEl = mark.note ? noteEls.current.get(mark.note) : undefined;\n        if (mark.note && noteEl) {\n          notes[mark.note] = t - 0.05;\n          const from = local(noteEl.getBoundingClientRect());\n          const target =\n            mark.type === \"circle\" || mark.type === \"box\"\n              ? whole\n              : boxes.reduce((best, b) => (distance(from, b) < distance(from, best) ? b : best));\n          const path = arrow(from, target, rand);\n          const shaftAt = t + 0.25;\n          const shaftFor = pace(path.length, 0.25, 0.5);\n          strokes.push({ key: `${index}-shaft`, d: path.shaft, delay: shaftAt, duration: shaftFor, tone: mark.tone });\n          strokes.push({ key: `${index}-head`, d: path.head, delay: shaftAt + shaftFor, duration: 0.14, tone: mark.tone });\n        }\n        clock = Math.max(clock + 0.2, t - 0.08);\n      });\n      setDrawing({ strokes, notes });\n    };\n    const schedule = () => {\n      cancelAnimationFrame(frame);\n      frame = requestAnimationFrame(measure);\n    };\n    measure();\n    const observer = new ResizeObserver(schedule);\n    observer.observe(root);\n    document.fonts?.ready.then(schedule);\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [version]);\n\n  const active = inView && run > 0;\n  const value = useMemo(\n    () => ({ prefix, tone, active, reduce, run, notes: drawing.notes, addMark, addNote }),\n    [prefix, tone, active, reduce, run, drawing.notes, addMark, addNote],\n  );\n\n  return (\n    <Context.Provider value={value}>\n      <div className={cn(\"relative\", className)} ref={ref} {...props}>\n        {children}\n        <svg aria-hidden className=\"pointer-events-none absolute inset-0 size-full overflow-visible\" fill=\"none\">\n          {drawing.strokes.map((stroke) => (\n            <motion.path\n              animate={active ? { pathLength: 1, opacity: 1 } : { pathLength: reduce ? 1 : 0, opacity: 0 }}\n              className={tones[stroke.tone]}\n              d={stroke.d}\n              initial={{ pathLength: reduce ? 1 : 0, opacity: 0 }}\n              key={`${run}-${stroke.key}`}\n              stroke=\"currentColor\"\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              strokeWidth={strokeWidth}\n              transition={\n                reduce\n                  ? { duration: 0.3, delay: active ? stroke.delay * 0.25 : 0 }\n                  : {\n                      pathLength: { delay: stroke.delay, duration: stroke.duration, ease: [0.45, 0.05, 0.25, 1] },\n                      opacity: { delay: active ? stroke.delay : 0, duration: 0.01 },\n                    }\n              }\n            />\n          ))}\n        </svg>\n      </div>\n    </Context.Provider>\n  );\n}\n\nexport interface MarkProps extends ComponentProps<\"span\"> {\n  type?: MarkType;\n  tone?: MarkTone;\n  note?: string;\n}\n\nexport function Mark({ type = \"underline\", tone, note, className, children, ...props }: MarkProps) {\n  const ctx = useMarginNotes();\n  const id = useId();\n  const ref = useRef<HTMLSpanElement>(null);\n  const resolved = tone ?? ctx.tone;\n  const { addMark } = ctx;\n\n  useLayoutEffect(() => {\n    if (!ref.current) return;\n    return addMark(id, { el: ref.current, type, tone: resolved, note });\n  }, [addMark, id, type, resolved, note]);\n\n  return (\n    <span\n      aria-describedby={note ? `${ctx.prefix}-${note}` : undefined}\n      className={cn((type === \"circle\" || type === \"box\") && \"mx-1.5 whitespace-nowrap\", className)}\n      ref={ref}\n      {...props}\n    >\n      {children}\n    </span>\n  );\n}\n\nexport interface MarginNoteProps extends Omit<ComponentProps<\"span\">, \"id\"> {\n  id: string;\n  tone?: MarkTone;\n}\n\nexport function MarginNote({ id, tone, className, children, style, ...props }: MarginNoteProps) {\n  const ctx = useMarginNotes();\n  const ref = useRef<HTMLSpanElement>(null);\n  const { addNote } = ctx;\n  const delay = ctx.notes[id];\n  const shown = ctx.active && delay !== undefined;\n  const hidden = { opacity: 0, y: ctx.reduce ? 0 : 3, filter: ctx.reduce ? \"blur(0px)\" : \"blur(3px)\" };\n\n  useLayoutEffect(() => {\n    if (!ref.current) return;\n    return addNote(id, ref.current);\n  }, [addNote, id]);\n\n  return (\n    <span className={cn(\"inline-block\", tones[tone ?? ctx.tone], className)} id={`${ctx.prefix}-${id}`} ref={ref} style={style} {...props}>\n      <motion.span\n        animate={shown ? { opacity: 1, y: 0, filter: \"blur(0px)\" } : hidden}\n        className=\"block\"\n        initial={hidden}\n        key={ctx.run}\n        transition={{ duration: 0.3, delay: shown ? (ctx.reduce ? delay * 0.25 : delay) : 0, ease: [0.23, 1, 0.32, 1] }}\n      >\n        {children}\n      </motion.span>\n    </span>\n  );\n}\n"
    }
  ]
}