{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "annotation-layer",
  "title": "Annotation Layer",
  "description": "Notes attached to regions of a page, created by dragging.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/annotation-layer/annotation-layer.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { MessageSquare, Trash2 } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type Annotation = {\n  id: string\n  x: number\n  y: number\n  width: number\n  height: number\n  note?: string\n  author?: string\n}\n\nexport type AnnotationRect = Pick<Annotation, \"x\" | \"y\" | \"width\" | \"height\">\n\nexport type AnnotationLayerProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onSelect\"\n> & {\n  src: string\n  alt: string\n  annotations: Annotation[]\n  activeId?: string | null\n  defaultActiveId?: string | null\n  onActiveChange?: (id: string | null) => void\n  onCreate?: (rect: AnnotationRect) => void\n  onDelete?: (id: string) => void\n  readOnly?: boolean\n  minSize?: number\n  label?: string\n  renderImage?: (props: {\n    src: string\n    alt: string\n    className: string\n  }) => React.ReactNode\n}\n\ntype Draft = { startX: number; startY: number; x: number; y: number }\n\nfunction clamp(value: number) {\n  return Math.max(0, Math.min(1, value))\n}\n\nfunction toPercent(value: number) {\n  return `${clamp(value) * 100}%`\n}\n\nfunction rectOf(draft: Draft): AnnotationRect {\n  return {\n    x: Math.min(draft.startX, draft.x),\n    y: Math.min(draft.startY, draft.y),\n    width: Math.abs(draft.x - draft.startX),\n    height: Math.abs(draft.y - draft.startY),\n  }\n}\n\nexport function AnnotationLayer({\n  src,\n  alt,\n  annotations,\n  activeId,\n  defaultActiveId = null,\n  onActiveChange,\n  onCreate,\n  onDelete,\n  readOnly = false,\n  minSize = 0.01,\n  label = \"Annotations\",\n  renderImage,\n  className,\n  ...rootProps\n}: AnnotationLayerProps) {\n  const surfaceRef = React.useRef<HTMLDivElement>(null)\n  const [draft, setDraft] = React.useState<Draft | null>(null)\n  const [uncontrolled, setUncontrolled] = React.useState(defaultActiveId)\n\n  const selected = activeId === undefined ? uncontrolled : activeId\n  const drawable = !readOnly && Boolean(onCreate)\n\n  const select = (id: string | null) => {\n    if (activeId === undefined) setUncontrolled(id)\n    onActiveChange?.(id)\n  }\n\n  const pointFrom = (event: React.PointerEvent) => {\n    const bounds = surfaceRef.current?.getBoundingClientRect()\n    if (!bounds || bounds.width === 0) return null\n\n    return {\n      x: clamp((event.clientX - bounds.left) / bounds.width),\n      y: clamp((event.clientY - bounds.top) / bounds.height),\n    }\n  }\n\n  const active = annotations.find((entry) => entry.id === selected)\n  const imageClassName = \"block h-auto w-full select-none\"\n\n  return (\n    <div\n      data-slot=\"annotation-layer\"\n      className={cn(\"grid gap-3\", className)}\n      {...rootProps}\n    >\n      <div\n        ref={surfaceRef}\n        data-slot=\"annotation-surface\"\n        className={cn(\n          \"border-border bg-muted relative overflow-hidden rounded-[var(--radius)] border\",\n          drawable && \"cursor-crosshair\"\n        )}\n        onPointerDown={(event) => {\n          if (!drawable || event.button !== 0) return\n          const point = pointFrom(event)\n          if (!point) return\n\n          event.currentTarget.setPointerCapture(event.pointerId)\n          setDraft({ startX: point.x, startY: point.y, ...point })\n        }}\n        onPointerMove={(event) => {\n          if (!draft) return\n          const point = pointFrom(event)\n          if (!point) return\n\n          setDraft((current) => (current ? { ...current, ...point } : current))\n        }}\n        onPointerUp={() => {\n          if (!draft) return\n\n          const rect = rectOf(draft)\n          setDraft(null)\n\n          // A click that never moved is a deselect, not a new annotation.\n          if (rect.width < minSize || rect.height < minSize) {\n            select(null)\n            return\n          }\n\n          onCreate?.(rect)\n        }}\n        onPointerCancel={() => setDraft(null)}\n      >\n        {renderImage ? (\n          renderImage({ src, alt, className: imageClassName })\n        ) : (\n          // eslint-disable-next-line @next/next/no-img-element\n          <img\n            alt={alt}\n            className={imageClassName}\n            src={src}\n            draggable={false}\n          />\n        )}\n\n        <ul aria-label={label} className=\"absolute inset-0 m-0 list-none p-0\">\n          {annotations.map((annotation, index) => {\n            const isActive = annotation.id === selected\n\n            return (\n              <li\n                key={annotation.id}\n                className=\"absolute\"\n                style={{\n                  left: toPercent(annotation.x),\n                  top: toPercent(annotation.y),\n                  width: toPercent(annotation.width),\n                  height: toPercent(annotation.height),\n                }}\n              >\n                <button\n                  type=\"button\"\n                  data-slot=\"annotation\"\n                  data-active={isActive || undefined}\n                  aria-pressed={isActive}\n                  className={cn(\n                    \"focus-visible:ring-ring size-full rounded-[3px] border-2 focus-visible:ring-2 focus-visible:outline-none\",\n                    isActive\n                      ? \"border-primary bg-primary/20\"\n                      : \"border-primary/60 bg-primary/10 hover:bg-primary/20 transition-colors duration-150 motion-reduce:transition-none\"\n                  )}\n                  onPointerDown={(event) => event.stopPropagation()}\n                  onClick={() => select(isActive ? null : annotation.id)}\n                >\n                  <span className=\"sr-only\">\n                    Note {index + 1}\n                    {annotation.note ? `: ${annotation.note}` : \"\"}\n                  </span>\n                </button>\n              </li>\n            )\n          })}\n        </ul>\n\n        {draft ? (\n          <div\n            aria-hidden=\"true\"\n            data-slot=\"annotation-draft\"\n            className=\"border-primary bg-primary/10 pointer-events-none absolute border-2 border-dashed\"\n            style={{\n              left: toPercent(rectOf(draft).x),\n              top: toPercent(rectOf(draft).y),\n              width: toPercent(rectOf(draft).width),\n              height: toPercent(rectOf(draft).height),\n            }}\n          />\n        ) : null}\n      </div>\n\n      <div\n        data-slot=\"annotation-detail\"\n        aria-live=\"polite\"\n        className=\"text-muted-foreground min-h-9 text-sm\"\n      >\n        {active ? (\n          <div className=\"border-border bg-card flex items-start gap-2.5 rounded-[var(--radius)] border px-3 py-2\">\n            <MessageSquare\n              aria-hidden=\"true\"\n              size={14}\n              className=\"mt-0.5 shrink-0\"\n            />\n            <p className=\"text-foreground min-w-0 flex-1\">\n              {active.note ?? \"No note yet.\"}\n              {active.author ? (\n                <span className=\"text-muted-foreground\">\n                  {\" \"}\n                  — {active.author}\n                </span>\n              ) : null}\n            </p>\n\n            {onDelete && !readOnly ? (\n              <button\n                type=\"button\"\n                className=\"hover:text-destructive focus-visible:ring-ring -my-1 inline-flex size-9 shrink-0 items-center justify-center rounded-full transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\"\n                onClick={() => {\n                  onDelete(active.id)\n                  select(null)\n                }}\n              >\n                <span className=\"sr-only\">Delete this note</span>\n                <Trash2 aria-hidden=\"true\" size={14} />\n              </button>\n            ) : null}\n          </div>\n        ) : (\n          <p>\n            {drawable\n              ? \"Drag on the page to add a note.\"\n              : \"Select a highlighted region to read its note.\"}\n          </p>\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "document",
    "annotation",
    "review"
  ],
  "type": "registry:ui"
}