{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "canvas-view",
  "title": "Canvas View",
  "description": "Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support.",
  "dependencies": [
    "@vllnt/ui@^0.3.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/default/canvas-view/canvas-view.tsx",
      "content": "\"use client\";\n\nimport {\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} 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  if (axis === \"x\") {\n    if (element.scrollWidth <= element.clientWidth) {\n      return false;\n    }\n\n    return supportsScrollableOverflow(\n      window.getComputedStyle(element).overflowX,\n    );\n  }\n\n  if (element.scrollHeight <= element.clientHeight) {\n    return false;\n  }\n\n  return supportsScrollableOverflow(window.getComputedStyle(element).overflowY);\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(\n  event: WheelEvent,\n  container: HTMLDivElement,\n) {\n  if (\n    isHtmlElement(event.target) &&\n    hasScrollableAncestor(event.target, container, {\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  containerRef,\n  nudgeViewport,\n  resetViewport,\n  setViewport,\n  viewportRef,\n  zoomStep,\n}: {\n  containerRef: React.RefObject<HTMLDivElement | null>;\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  // React 19 marks the synthetic onWheel handler passive, which drops\n  // preventDefault(). useCanvasWheel attaches a native non-passive listener.\n  useCanvasWheel({\n    containerRef,\n    nudgeViewport,\n    setViewport,\n    viewportRef,\n    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  const handleBlur = useCallback(() => {\n    setIsSpacePressed(false);\n  }, []);\n\n  return { handleBlur, handleKeyDown, handleKeyUp, isSpacePressed };\n}\n\nfunction useCanvasWheel({\n  containerRef,\n  nudgeViewport,\n  setViewport,\n  viewportRef,\n  zoomStep,\n}: {\n  containerRef: React.RefObject<HTMLDivElement | null>;\n  nudgeViewport: (deltaX: number, deltaY: number) => void;\n  setViewport: (viewport: CanvasViewport) => void;\n  viewportRef: ViewportReference;\n  zoomStep: number;\n}) {\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n\n    const handleWheel = (event: WheelEvent) => {\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, container)) {\n        return;\n      }\n\n      event.preventDefault();\n      nudgeViewport(-event.deltaX, -event.deltaY);\n    };\n\n    container.addEventListener(\"wheel\", handleWheel, { passive: false });\n\n    return () => {\n      container.removeEventListener(\"wheel\", handleWheel);\n    };\n  }, [containerRef, nudgeViewport, setViewport, viewportRef, zoomStep]);\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.Ref<CanvasViewHandle> | undefined,\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  containerRef: React.RefObject<HTMLDivElement | null>;\n  instructionsId: string;\n  isDragging: boolean;\n  isSpacePressed: boolean;\n  onBlur: () => void;\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  viewport: CanvasViewport;\n};\n\nfunction CanvasInteractionLayer({\n  children,\n  containerRef,\n  instructionsId,\n  isDragging,\n  isSpacePressed,\n  onBlur,\n  onKeyDown,\n  onKeyUp,\n  onPointerCancel,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\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      onBlur={onBlur}\n      onKeyDown={onKeyDown}\n      onKeyUp={onKeyUp}\n      onPointerCancel={onPointerCancel}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={onPointerUp}\n      ref={containerRef}\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  isDragging,\n  overlay,\n  viewport,\n}: {\n  children: React.ReactNode;\n  isDragging: boolean;\n  overlay?: React.ReactNode;\n  viewport: CanvasViewport;\n}) {\n  return (\n    <>\n      <div\n        className={cn(\n          \"absolute inset-0 origin-top-left\",\n          isDragging ? \"\" : \"transition-transform duration-150 ease-out\",\n        )}\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 = ({\n  children,\n  className,\n  defaultViewport = DEFAULT_VIEWPORT,\n  maxZoom = 2,\n  minZoom = 0.5,\n  onViewportChange,\n  overlay,\n  ref,\n  zoomStep = 0.1,\n  ...props\n}: CanvasViewProps & { ref?: React.Ref<CanvasViewHandle> }) => {\n  const instructionsId = useId();\n  const interactionRef = useRef<HTMLDivElement>(null);\n  const viewportState = useViewportState({\n    defaultViewport,\n    maxZoom,\n    minZoom,\n    onViewportChange,\n  });\n  const keyboard = useCanvasKeyboardInteractions({\n    containerRef: interactionRef,\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        containerRef={interactionRef}\n        instructionsId={instructionsId}\n        isDragging={pointer.isDragging}\n        isSpacePressed={keyboard.isSpacePressed}\n        onBlur={keyboard.handleBlur}\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        viewport={viewportState.viewport}\n      >\n        <CanvasContentLayer\n          isDragging={pointer.isDragging}\n          overlay={overlay}\n          viewport={viewportState.viewport}\n        >\n          {children}\n        </CanvasContentLayer>\n      </CanvasInteractionLayer>\n    </div>\n  );\n};\n\nCanvasView.displayName = \"CanvasView\";\n\nexport { CanvasView };\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component",
  "version": "0.3.0",
  "stability": "stable"
}
