{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-canvas",
  "type": "registry:ui",
  "title": "Infinite Canvas",
  "description": "Pan-and-zoom spatial workspace for arranging content without fixed bounds.",
  "dependencies": [
    "lucide-react@^1.27.0"
  ],
  "registryDependencies": [
    "http://127.0.0.1:3000/r/button.json",
    "http://127.0.0.1:3000/r/core.json"
  ],
  "files": [
    {
      "path": "src/registry/sources/control-ui/ui/infinite-canvas.tsx",
      "target": "@components/control-ui/ui/infinite-canvas.tsx",
      "type": "registry:ui",
      "content": "// biome-ignore-all lint/a11y/noNoninteractiveTabindex: Infinite Canvas is a composite pan-and-zoom widget with documented keyboard commands.\n\"use client\";\n\nimport { MinusIcon, PlusIcon } from \"lucide-react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from \"react\";\nimport { createContext, use, useEffect, useEffectEvent, useRef, useState } from \"react\";\nimport type {\n  InfiniteCanvasContentProps,\n  InfiniteCanvasControlsProps,\n  InfiniteCanvasMoveReason,\n  InfiniteCanvasProps,\n  InfiniteCanvasTransform,\n} from \"@/components/control-ui/contracts\";\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { skinSlot } from \"@/components/control-ui/skin\";\nimport { Button } from \"@/components/control-ui/ui/button\";\n\nconst DEFAULT_TRANSFORM: InfiniteCanvasTransform = { x: 0, y: 0, scale: 1 };\nconst DEFAULT_MIN_SCALE = 0.35;\nconst DEFAULT_MAX_SCALE = 2.5;\nconst KEYBOARD_PAN_STEP = 32;\nconst GRID_SIZE = 24;\nconst KEYBOARD_PAN_DIRECTIONS: Partial<Record<string, { x: number; y: number }>> = {\n  ArrowLeft: { x: -1, y: 0 },\n  ArrowRight: { x: 1, y: 0 },\n  ArrowUp: { x: 0, y: -1 },\n  ArrowDown: { x: 0, y: 1 },\n};\n\ntype InfiniteCanvasPanSession = {\n  pointerId: number;\n  startPointer: { x: number; y: number };\n  startTransform: InfiniteCanvasTransform;\n};\n\ntype InfiniteCanvasContextValue = {\n  transform: InfiniteCanvasTransform;\n  minScale: number;\n  maxScale: number;\n  reset: (reason: InfiniteCanvasMoveReason) => void;\n  zoomBy: (factor: number, reason: InfiniteCanvasMoveReason) => void;\n};\n\nconst InfiniteCanvasContext = createContext<InfiniteCanvasContextValue | null>(null);\n\nfunction clampScale(scale: number, minScale: number, maxScale: number) {\n  return Math.min(Math.max(scale, minScale), maxScale);\n}\n\nfunction zoomAroundPoint(transform: InfiniteCanvasTransform, nextScale: number, point: { x: number; y: number }): InfiniteCanvasTransform {\n  const worldX = (point.x - transform.x) / transform.scale;\n  const worldY = (point.y - transform.y) / transform.scale;\n  return {\n    x: point.x - worldX * nextScale,\n    y: point.y - worldY * nextScale,\n    scale: nextScale,\n  };\n}\n\nfunction panFromWheel(transform: InfiniteCanvasTransform, event: WheelEvent, viewportHeight: number): InfiniteCanvasTransform {\n  let deltaMultiplier = 1;\n  if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) deltaMultiplier = 16;\n  else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) deltaMultiplier = viewportHeight;\n  const horizontalDelta = event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX;\n  const verticalDelta = event.shiftKey && event.deltaX === 0 ? 0 : event.deltaY;\n  return {\n    x: transform.x - horizontalDelta * deltaMultiplier,\n    y: transform.y - verticalDelta * deltaMultiplier,\n    scale: transform.scale,\n  };\n}\n\nexport function useInfiniteCanvas() {\n  const context = use(InfiniteCanvasContext);\n  if (!context) throw new Error(\"useInfiniteCanvas must be used within InfiniteCanvas\");\n  return context;\n}\n\nexport function InfiniteCanvas({\n  transform: controlledTransform,\n  defaultTransform = DEFAULT_TRANSFORM,\n  onTransformChange,\n  minScale: minScaleProp = DEFAULT_MIN_SCALE,\n  maxScale: maxScaleProp = DEFAULT_MAX_SCALE,\n  className,\n  children,\n  style,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onWheel,\n  onKeyDown,\n  \"aria-label\": ariaLabel = \"Infinite canvas\",\n  ...props\n}: InfiniteCanvasProps) {\n  const minScale = Math.max(0.1, minScaleProp);\n  const maxScale = Math.max(minScale, maxScaleProp);\n  const [uncontrolledTransform, setUncontrolledTransform] = useState(defaultTransform);\n  const [panning, setPanning] = useState(false);\n  const panRef = useRef<InfiniteCanvasPanSession | null>(null);\n  const rootRef = useRef<HTMLElement>(null);\n  const transform = controlledTransform ?? uncontrolledTransform;\n  const transformRef = useRef(transform);\n\n  useEffect(() => {\n    transformRef.current = transform;\n  }, [transform]);\n\n  function commitTransform(next: InfiniteCanvasTransform, reason: InfiniteCanvasMoveReason) {\n    const bounded = { ...next, scale: clampScale(next.scale, minScale, maxScale) };\n    transformRef.current = bounded;\n    if (controlledTransform === undefined) setUncontrolledTransform(bounded);\n    onTransformChange?.(bounded, { reason });\n  }\n\n  function zoomBy(factor: number, reason: InfiniteCanvasMoveReason) {\n    const currentTransform = transformRef.current;\n    const nextScale = clampScale(currentTransform.scale * factor, minScale, maxScale);\n    const root = rootRef.current;\n    const point = root ? { x: root.clientWidth / 2, y: root.clientHeight / 2 } : { x: 0, y: 0 };\n    commitTransform(zoomAroundPoint(currentTransform, nextScale, point), reason);\n  }\n\n  function reset(reason: InfiniteCanvasMoveReason) {\n    commitTransform(defaultTransform, reason);\n  }\n\n  function beginPan(event: ReactPointerEvent<HTMLElement>) {\n    onPointerDown?.(event);\n    if (event.defaultPrevented || event.target !== event.currentTarget || !event.isPrimary || event.button !== 0) return;\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    panRef.current = {\n      pointerId: event.pointerId,\n      startPointer: { x: event.clientX, y: event.clientY },\n      startTransform: transformRef.current,\n    };\n    setPanning(true);\n  }\n\n  function updatePan(event: ReactPointerEvent<HTMLElement>) {\n    onPointerMove?.(event);\n    if (event.defaultPrevented) return;\n    const pan = panRef.current;\n    if (!pan || pan.pointerId !== event.pointerId) return;\n    commitTransform(\n      {\n        x: pan.startTransform.x + event.clientX - pan.startPointer.x,\n        y: pan.startTransform.y + event.clientY - pan.startPointer.y,\n        scale: pan.startTransform.scale,\n      },\n      \"pointer\",\n    );\n  }\n\n  function finishPan(event: ReactPointerEvent<HTMLElement>) {\n    const pan = panRef.current;\n    if (!pan || pan.pointerId !== event.pointerId) return;\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);\n    panRef.current = null;\n    setPanning(false);\n  }\n\n  function endPan(event: ReactPointerEvent<HTMLElement>) {\n    onPointerUp?.(event);\n    if (!event.defaultPrevented) finishPan(event);\n  }\n\n  function cancelPan(event: ReactPointerEvent<HTMLElement>) {\n    onPointerCancel?.(event);\n    if (!event.defaultPrevented) finishPan(event);\n  }\n\n  const handleWheel = useEffectEvent((event: WheelEvent) => {\n    onWheel?.(event);\n    if (event.defaultPrevented) return;\n    const root = rootRef.current;\n    if (!root) return;\n    event.preventDefault();\n    const currentTransform = transformRef.current;\n    if (event.ctrlKey || event.metaKey) {\n      const bounds = root.getBoundingClientRect();\n      const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };\n      const nextScale = clampScale(currentTransform.scale * Math.exp(-event.deltaY * 0.004), minScale, maxScale);\n      commitTransform(zoomAroundPoint(currentTransform, nextScale, point), \"wheel\");\n      return;\n    }\n    commitTransform(panFromWheel(currentTransform, event, root.clientHeight), \"wheel\");\n  });\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const listener = (event: WheelEvent) => handleWheel(event);\n    root.addEventListener(\"wheel\", listener, { passive: false });\n    return () => root.removeEventListener(\"wheel\", listener);\n  }, []);\n\n  function moveWithKeyboard(event: ReactKeyboardEvent<HTMLElement>) {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    const currentTransform = transformRef.current;\n    const direction = KEYBOARD_PAN_DIRECTIONS[event.key];\n    if (direction) {\n      const panStep = event.shiftKey ? KEYBOARD_PAN_STEP * 3 : KEYBOARD_PAN_STEP;\n      commitTransform(\n        {\n          ...currentTransform,\n          x: currentTransform.x + direction.x * panStep,\n          y: currentTransform.y + direction.y * panStep,\n        },\n        \"keyboard\",\n      );\n    } else if (event.key === \"+\" || event.key === \"=\") zoomBy(1.15, \"keyboard\");\n    else if (event.key === \"-\") zoomBy(1 / 1.15, \"keyboard\");\n    else if (event.key === \"0\") reset(\"keyboard\");\n    else return;\n    event.preventDefault();\n  }\n\n  const context: InfiniteCanvasContextValue = { transform, minScale, maxScale, reset, zoomBy };\n\n  return (\n    <InfiniteCanvasContext.Provider value={context}>\n      <section\n        ref={rootRef}\n        {...props}\n        data-control-ui=\"infinite-canvas\"\n        data-slot=\"root\"\n        data-panning={panning || undefined}\n        aria-label={ariaLabel}\n        role=\"application\"\n        aria-keyshortcuts=\"ArrowLeft ArrowRight ArrowUp ArrowDown + - 0\"\n        tabIndex={0}\n        className={cn(\n          \"relative isolate overflow-hidden overscroll-contain rounded-[var(--radius-panel)] bg-canvas bg-radial-[circle_at_center,oklch(from_var(--foreground)_l_c_h/0.16)_1px,transparent_1px] outline-none ring-1 ring-border touch-none select-none focus-visible:ring-2 focus-visible:ring-ring data-panning:cursor-grabbing\",\n          skinSlot(\"infinite-canvas\", \"root\", { panning }),\n          className,\n        )}\n        style={{\n          ...style,\n          backgroundPosition: `${transform.x}px ${transform.y}px`,\n          backgroundSize: `${GRID_SIZE * transform.scale}px ${GRID_SIZE * transform.scale}px`,\n        }}\n        onPointerDown={beginPan}\n        onPointerMove={updatePan}\n        onPointerUp={endPan}\n        onPointerCancel={cancelPan}\n        onKeyDown={moveWithKeyboard}\n      >\n        {children}\n      </section>\n    </InfiniteCanvasContext.Provider>\n  );\n}\n\nexport function InfiniteCanvasContent({ className, style, ...props }: InfiniteCanvasContentProps) {\n  const { transform } = useInfiniteCanvas();\n  return (\n    <div\n      {...props}\n      data-control-ui=\"infinite-canvas\"\n      data-slot=\"content\"\n      data-scale={transform.scale}\n      className={cn(\n        \"pointer-events-none absolute top-0 left-0 h-0 w-0 transform-gpu [transform-origin:0_0] will-change-transform [&>*]:pointer-events-auto\",\n        skinSlot(\"infinite-canvas\", \"content\", { scale: transform.scale }),\n        className,\n      )}\n      style={{\n        ...style,\n        transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,\n      }}\n    />\n  );\n}\n\nexport function InfiniteCanvasControls({ className, ...props }: InfiniteCanvasControlsProps) {\n  const canvas = useInfiniteCanvas();\n  const percentage = Math.round(canvas.transform.scale * 100);\n  return (\n    <div\n      {...props}\n      data-control-ui=\"infinite-canvas\"\n      data-slot=\"controls\"\n      className={cn(\n        \"absolute right-3 bottom-3 z-40 flex items-center gap-0.5 rounded-[var(--radius-control)] bg-card p-1 shadow-sm ring-1 ring-border\",\n        skinSlot(\"infinite-canvas\", \"controls\", {}),\n        className,\n      )}\n    >\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        iconOnly\n        disabled={canvas.transform.scale <= canvas.minScale}\n        aria-label=\"Zoom out\"\n        onClick={() => canvas.zoomBy(1 / 1.2, \"control\")}\n      >\n        <MinusIcon />\n      </Button>\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        aria-label={`Reset canvas zoom, currently ${percentage}%`}\n        onClick={() => canvas.reset(\"control\")}\n      >\n        {percentage}%\n      </Button>\n      <Button\n        type=\"button\"\n        size=\"sm\"\n        variant=\"ghost\"\n        iconOnly\n        disabled={canvas.transform.scale >= canvas.maxScale}\n        aria-label=\"Zoom in\"\n        onClick={() => canvas.zoomBy(1.2, \"control\")}\n      >\n        <PlusIcon />\n      </Button>\n    </div>\n  );\n}\n"
    }
  ],
  "meta": {}
}
