{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "canvas-view",
  "type": "registry:component",
  "title": "Canvas View",
  "description": "Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support.",
  "dependencies": [
    "@vllnt/ui@^0.2.1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/canvas-view/canvas-view.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useId,\n  useImperativeHandle,\n  useRef,\n  useState,\n} from \"react\";\n\nimport type {\n  KeyboardEvent as ReactKeyboardEvent,\n  PointerEvent as ReactPointerEvent,\n  WheelEvent as ReactWheelEvent,\n} from \"react\";\n\nimport { cn } from \"@vllnt/ui\";\n\nexport type CanvasViewport = {\n  x: number;\n  y: number;\n  zoom: number;\n};\n\nexport type CanvasViewHandle = {\n  resetViewport: () => void;\n  setViewport: (viewport: CanvasViewport) => void;\n};\n\nexport type CanvasViewProps = Omit<\n  React.ComponentPropsWithoutRef<\"div\">,\n  \"onScroll\"\n> & {\n  defaultViewport?: CanvasViewport;\n  maxZoom?: number;\n  minZoom?: number;\n  onViewportChange?: (viewport: CanvasViewport) => void;\n  overlay?: React.ReactNode;\n  zoomStep?: number;\n};\n\ntype DragOrigin = {\n  pointerX: number;\n  pointerY: number;\n  viewport: CanvasViewport;\n};\n\ntype ViewportReference = {\n  current: CanvasViewport;\n};\n\nconst DEFAULT_VIEWPORT: CanvasViewport = { x: 0, y: 0, zoom: 1 };\n\nconst INTERACTIVE_ELEMENT_SELECTOR = [\n  \"a[href]\",\n  \"button\",\n  'input:not([type=\"hidden\"])',\n  \"select\",\n  \"textarea\",\n  \"summary\",\n  '[contenteditable=\"\"]',\n  '[contenteditable=\"true\"]',\n  '[role=\"button\"]',\n  '[role=\"checkbox\"]',\n  '[role=\"link\"]',\n  '[role=\"menuitem\"]',\n  '[role=\"option\"]',\n  '[role=\"radio\"]',\n  '[role=\"slider\"]',\n  '[role=\"spinbutton\"]',\n  '[role=\"switch\"]',\n  '[role=\"tab\"]',\n  '[role=\"textbox\"]',\n].join(\", \");\n\nfunction clampZoom(value: number, minZoom: number, maxZoom: number) {\n  return Math.min(maxZoom, Math.max(minZoom, Number(value.toFixed(2))));\n}\n\nfunction isHtmlElement(target: EventTarget | null): target is HTMLElement {\n  return target instanceof HTMLElement;\n}\n\nfunction isInteractiveDescendant(\n  element: HTMLElement,\n  container: HTMLDivElement,\n) {\n  const interactiveAncestor = element.closest(INTERACTIVE_ELEMENT_SELECTOR);\n\n  return (\n    interactiveAncestor !== null && container.contains(interactiveAncestor)\n  );\n}\n\nfunction supportsScrollableOverflow(value: string) {\n  return value === \"auto\" || value === \"overlay\" || value === \"scroll\";\n}\n\nfunction hasScrollableAxis(element: HTMLElement, axis: \"x\" | \"y\") {\n  const style = window.getComputedStyle(element);\n\n  if (axis === \"x\") {\n    return (\n      supportsScrollableOverflow(style.overflowX) &&\n      element.scrollWidth > element.clientWidth\n    );\n  }\n\n  return (\n    supportsScrollableOverflow(style.overflowY) &&\n    element.scrollHeight > element.clientHeight\n  );\n}\n\nfunction hasScrollableAncestor(\n  element: HTMLElement,\n  container: HTMLDivElement,\n  delta: { x: number; y: number },\n): boolean {\n  if (!container.contains(element) || element === container) {\n    return false;\n  }\n\n  if (\n    (delta.x !== 0 && hasScrollableAxis(element, \"x\")) ||\n    (delta.y !== 0 && hasScrollableAxis(element, \"y\"))\n  ) {\n    return true;\n  }\n\n  return element.parentElement === null\n    ? false\n    : hasScrollableAncestor(element.parentElement, container, delta);\n}\n\nfunction shouldHandleCanvasKeyboardEvent(\n  event: ReactKeyboardEvent<HTMLDivElement>,\n) {\n  if (\n    isHtmlElement(event.target) &&\n    event.target !== event.currentTarget &&\n    isInteractiveDescendant(event.target, event.currentTarget)\n  ) {\n    return false;\n  }\n\n  return true;\n}\n\nfunction shouldHandleCanvasWheelEvent(event: ReactWheelEvent<HTMLDivElement>) {\n  if (\n    isHtmlElement(event.target) &&\n    hasScrollableAncestor(event.target, event.currentTarget, {\n      x: event.deltaX,\n      y: event.deltaY,\n    })\n  ) {\n    return false;\n  }\n\n  return true;\n}\n\nfunction isPanGesture(\n  event: ReactPointerEvent<HTMLDivElement>,\n  isSpacePressed: boolean,\n) {\n  return event.button === 1 || (event.button === 0 && isSpacePressed);\n}\n\nfunction createViewportKeyHandler({\n  nudgeViewport,\n  resetViewport,\n  setViewport,\n  viewportRef,\n  zoomStep,\n}: {\n  nudgeViewport: (deltaX: number, deltaY: number) => void;\n  resetViewport: () => void;\n  setViewport: (viewport: CanvasViewport) => void;\n  viewportRef: ViewportReference;\n  zoomStep: number;\n}) {\n  return (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"+\" || event.key === \"=\") {\n      event.preventDefault();\n      setViewport({\n        ...viewportRef.current,\n        zoom: viewportRef.current.zoom + zoomStep,\n      });\n      return;\n    }\n\n    if (event.key === \"-\") {\n      event.preventDefault();\n      setViewport({\n        ...viewportRef.current,\n        zoom: viewportRef.current.zoom - zoomStep,\n      });\n      return;\n    }\n\n    if (event.key === \"0\") {\n      event.preventDefault();\n      resetViewport();\n      return;\n    }\n\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      nudgeViewport(40, 0);\n      return;\n    }\n\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      nudgeViewport(-40, 0);\n      return;\n    }\n\n    if (event.key === \"ArrowUp\") {\n      event.preventDefault();\n      nudgeViewport(0, 40);\n      return;\n    }\n\n    if (event.key === \"ArrowDown\") {\n      event.preventDefault();\n      nudgeViewport(0, -40);\n    }\n  };\n}\n\nfunction useViewportState({\n  defaultViewport,\n  maxZoom,\n  minZoom,\n  onViewportChange,\n}: {\n  defaultViewport: CanvasViewport;\n  maxZoom: number;\n  minZoom: number;\n  onViewportChange?: (viewport: CanvasViewport) => void;\n}) {\n  const defaultViewportRef = useRef(defaultViewport);\n  const viewportRef = useRef(defaultViewport);\n  const [viewport, setViewport] = useState(defaultViewport);\n\n  useEffect(() => {\n    defaultViewportRef.current = defaultViewport;\n  }, [defaultViewport]);\n\n  const applyViewport = useCallback(\n    (nextViewport: CanvasViewport) => {\n      const resolvedViewport = {\n        x: Math.round(nextViewport.x),\n        y: Math.round(nextViewport.y),\n        zoom: clampZoom(nextViewport.zoom, minZoom, maxZoom),\n      };\n\n      viewportRef.current = resolvedViewport;\n      setViewport(resolvedViewport);\n      onViewportChange?.(resolvedViewport);\n    },\n    [maxZoom, minZoom, onViewportChange],\n  );\n\n  const resetViewport = useCallback(() => {\n    applyViewport(defaultViewportRef.current);\n  }, [applyViewport]);\n\n  const nudgeViewport = useCallback(\n    (deltaX: number, deltaY: number) => {\n      const currentViewport = viewportRef.current;\n      applyViewport({\n        x: currentViewport.x + deltaX,\n        y: currentViewport.y + deltaY,\n        zoom: currentViewport.zoom,\n      });\n    },\n    [applyViewport],\n  );\n\n  return {\n    nudgeViewport,\n    resetViewport,\n    setViewport: applyViewport,\n    viewport,\n    viewportRef,\n  };\n}\n\nfunction useCanvasKeyboardInteractions({\n  nudgeViewport,\n  resetViewport,\n  setViewport,\n  viewportRef,\n  zoomStep,\n}: {\n  nudgeViewport: (deltaX: number, deltaY: number) => void;\n  resetViewport: () => void;\n  setViewport: (viewport: CanvasViewport) => void;\n  viewportRef: ViewportReference;\n  zoomStep: number;\n}) {\n  const [isSpacePressed, setIsSpacePressed] = useState(false);\n\n  const handleWheel = useCallback(\n    (event: ReactWheelEvent<HTMLDivElement>) => {\n      if (event.ctrlKey || event.metaKey) {\n        event.preventDefault();\n        setViewport({\n          ...viewportRef.current,\n          zoom:\n            viewportRef.current.zoom +\n            (event.deltaY > 0 ? -zoomStep : zoomStep),\n        });\n        return;\n      }\n\n      if (!shouldHandleCanvasWheelEvent(event)) {\n        return;\n      }\n\n      event.preventDefault();\n      nudgeViewport(-event.deltaX, -event.deltaY);\n    },\n    [nudgeViewport, setViewport, viewportRef, zoomStep],\n  );\n\n  const handleKeyDown = useCallback(\n    (event: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (!shouldHandleCanvasKeyboardEvent(event)) {\n        return;\n      }\n\n      if (event.key === \" \") {\n        event.preventDefault();\n        setIsSpacePressed(true);\n        return;\n      }\n\n      createViewportKeyHandler({\n        nudgeViewport,\n        resetViewport,\n        setViewport,\n        viewportRef,\n        zoomStep,\n      })(event);\n    },\n    [nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep],\n  );\n\n  const handleKeyUp = useCallback(\n    (event: ReactKeyboardEvent<HTMLDivElement>) => {\n      if (!shouldHandleCanvasKeyboardEvent(event)) {\n        return;\n      }\n\n      if (event.key === \" \") {\n        setIsSpacePressed(false);\n      }\n    },\n    [],\n  );\n\n  return { handleKeyDown, handleKeyUp, handleWheel, isSpacePressed };\n}\n\nfunction endCanvasDrag(\n  event: ReactPointerEvent<HTMLDivElement>,\n  dragOriginRef: React.RefObject<DragOrigin | null>,\n  setIsDragging: React.Dispatch<React.SetStateAction<boolean>>,\n) {\n  dragOriginRef.current = null;\n  setIsDragging(false);\n  if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n    event.currentTarget.releasePointerCapture(event.pointerId);\n  }\n}\n\nfunction useCanvasPointerInteractions({\n  isSpacePressed,\n  setViewport,\n  viewportRef,\n}: {\n  isSpacePressed: boolean;\n  setViewport: (viewport: CanvasViewport) => void;\n  viewportRef: ViewportReference;\n}) {\n  const dragOriginRef = useRef<DragOrigin | null>(null);\n  const [isDragging, setIsDragging] = useState(false);\n\n  const handlePointerDown = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      if (!isPanGesture(event, isSpacePressed)) {\n        return;\n      }\n\n      if (event.button === 1) {\n        event.preventDefault();\n      }\n\n      dragOriginRef.current = {\n        pointerX: event.clientX,\n        pointerY: event.clientY,\n        viewport: viewportRef.current,\n      };\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setIsDragging(true);\n    },\n    [isSpacePressed, viewportRef],\n  );\n\n  const handlePointerMove = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      const dragOrigin = dragOriginRef.current;\n      if (!dragOrigin) {\n        return;\n      }\n\n      setViewport({\n        x: dragOrigin.viewport.x + (event.clientX - dragOrigin.pointerX),\n        y: dragOrigin.viewport.y + (event.clientY - dragOrigin.pointerY),\n        zoom: dragOrigin.viewport.zoom,\n      });\n    },\n    [setViewport],\n  );\n\n  const handlePointerCancel = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      endCanvasDrag(event, dragOriginRef, setIsDragging);\n    },\n    [],\n  );\n\n  const handlePointerUp = useCallback(\n    (event: ReactPointerEvent<HTMLDivElement>) => {\n      endCanvasDrag(event, dragOriginRef, setIsDragging);\n    },\n    [],\n  );\n\n  return {\n    handlePointerCancel,\n    handlePointerDown,\n    handlePointerMove,\n    handlePointerUp,\n    isDragging,\n  };\n}\n\nfunction usePreventBodySelection(disabled: boolean) {\n  useEffect(() => {\n    if (typeof document === \"undefined\") {\n      return;\n    }\n\n    const { body } = document;\n    const previousUserSelect = body.style.userSelect;\n\n    if (disabled) {\n      body.style.userSelect = \"none\";\n    }\n\n    return () => {\n      body.style.userSelect = previousUserSelect;\n    };\n  }, [disabled]);\n}\n\nfunction useCanvasViewHandle(\n  ref: React.ForwardedRef<CanvasViewHandle>,\n  viewportState: {\n    resetViewport: () => void;\n    setViewport: (viewport: CanvasViewport) => void;\n  },\n) {\n  useImperativeHandle(\n    ref,\n    () => ({\n      resetViewport: viewportState.resetViewport,\n      setViewport: viewportState.setViewport,\n    }),\n    [viewportState.resetViewport, viewportState.setViewport],\n  );\n}\n\ntype CanvasInteractionLayerProps = {\n  children: React.ReactNode;\n  instructionsId: string;\n  isDragging: boolean;\n  isSpacePressed: boolean;\n  onKeyDown: (event: ReactKeyboardEvent<HTMLDivElement>) => void;\n  onKeyUp: (event: ReactKeyboardEvent<HTMLDivElement>) => void;\n  onPointerCancel: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerMove: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onPointerUp: (event: ReactPointerEvent<HTMLDivElement>) => void;\n  onWheel: (event: ReactWheelEvent<HTMLDivElement>) => void;\n  viewport: CanvasViewport;\n};\n\nfunction CanvasInteractionLayer({\n  children,\n  instructionsId,\n  isDragging,\n  isSpacePressed,\n  onKeyDown,\n  onKeyUp,\n  onPointerCancel,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onWheel,\n  viewport,\n}: CanvasInteractionLayerProps) {\n  return (\n    <div\n      aria-describedby={instructionsId}\n      aria-label=\"Canvas workspace\"\n      aria-roledescription=\"canvas\"\n      className={cn(\n        \"relative h-full w-full select-none touch-none outline-none\",\n        isDragging || isSpacePressed\n          ? \"cursor-grab active:cursor-grabbing\"\n          : \"cursor-default\",\n      )}\n      data-viewport={JSON.stringify(viewport)}\n      onKeyDown={onKeyDown}\n      onKeyUp={onKeyUp}\n      onPointerCancel={onPointerCancel}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      onWheel={onWheel}\n      role=\"button\"\n      tabIndex={0}\n    >\n      <div className=\"sr-only\" id={instructionsId}>\n        Hold space and drag or use the middle mouse button to pan. Use plus,\n        minus, or control wheel to zoom. Press zero to reset the viewport.\n      </div>\n      {children}\n    </div>\n  );\n}\n\nfunction CanvasContentLayer({\n  children,\n  overlay,\n  viewport,\n}: {\n  children: React.ReactNode;\n  overlay?: React.ReactNode;\n  viewport: CanvasViewport;\n}) {\n  return (\n    <>\n      <div\n        className=\"absolute inset-0 origin-top-left transition-transform duration-150 ease-out\"\n        style={{\n          transform: `translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.zoom})`,\n        }}\n      >\n        {children}\n      </div>\n      {overlay ? (\n        <div className=\"pointer-events-none absolute inset-0 z-20\">\n          {overlay}\n        </div>\n      ) : null}\n    </>\n  );\n}\n\nconst CanvasView = forwardRef<CanvasViewHandle, CanvasViewProps>(\n  (\n    {\n      children,\n      className,\n      defaultViewport = DEFAULT_VIEWPORT,\n      maxZoom = 2,\n      minZoom = 0.5,\n      onViewportChange,\n      overlay,\n      zoomStep = 0.1,\n      ...props\n    },\n    ref,\n  ) => {\n    const instructionsId = useId();\n    const viewportState = useViewportState({\n      defaultViewport,\n      maxZoom,\n      minZoom,\n      onViewportChange,\n    });\n    const keyboard = useCanvasKeyboardInteractions({\n      nudgeViewport: viewportState.nudgeViewport,\n      resetViewport: viewportState.resetViewport,\n      setViewport: viewportState.setViewport,\n      viewportRef: viewportState.viewportRef,\n      zoomStep,\n    });\n    const pointer = useCanvasPointerInteractions({\n      isSpacePressed: keyboard.isSpacePressed,\n      setViewport: viewportState.setViewport,\n      viewportRef: viewportState.viewportRef,\n    });\n    usePreventBodySelection(pointer.isDragging);\n    useCanvasViewHandle(ref, viewportState);\n\n    return (\n      <div\n        className={cn(\n          \"relative h-full min-h-[32rem] overflow-hidden rounded-sm border border-border bg-background\",\n          className,\n        )}\n        {...props}\n      >\n        <CanvasInteractionLayer\n          instructionsId={instructionsId}\n          isDragging={pointer.isDragging}\n          isSpacePressed={keyboard.isSpacePressed}\n          onKeyDown={keyboard.handleKeyDown}\n          onKeyUp={keyboard.handleKeyUp}\n          onPointerCancel={pointer.handlePointerCancel}\n          onPointerDown={pointer.handlePointerDown}\n          onPointerMove={pointer.handlePointerMove}\n          onPointerUp={pointer.handlePointerUp}\n          onWheel={keyboard.handleWheel}\n          viewport={viewportState.viewport}\n        >\n          <CanvasContentLayer\n            overlay={overlay}\n            viewport={viewportState.viewport}\n          >\n            {children}\n          </CanvasContentLayer>\n        </CanvasInteractionLayer>\n      </div>\n    );\n  },\n);\n\nCanvasView.displayName = \"CanvasView\";\n\nexport { CanvasView };\n",
      "type": "registry:component"
    }
  ],
  "version": "0.2.1",
  "stability": "stable"
}
