{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pan-zoom-canvas",
  "type": "registry:ui",
  "title": "Pan Zoom Canvas",
  "description": "An infinite dot-grid canvas that pans, pinches and zooms to the cursor.",
  "categories": [
    "gestures"
  ],
  "dependencies": [
    "@base-ui/react",
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/pan-zoom-canvas.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type AnimationPlaybackControls } from \"motion/react\";\nimport { createContext, useContext, useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Maximize, Minus, Plus } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type CanvasView = { x: number; y: number; scale: number };\n\ntype Api = {\n  zoomIn: () => void;\n  zoomOut: () => void;\n  /** Back to 100%, keeping the middle of the view where it is. */\n  reset: () => void;\n  /** Frame everything on the canvas. */\n  fit: (animated?: boolean) => void;\n  zoomTo: (scale: number, focal?: { x: number; y: number }, animated?: boolean) => void;\n  panBy: (dx: number, dy: number, animated?: boolean) => void;\n  scale: number;\n  minScale: number;\n  maxScale: number;\n};\n\nconst CanvasContext = createContext<Api | null>(null);\n\n/** Zoom and pan actions for custom controls rendered inside the canvas. */\nexport function usePanZoom() {\n  const api = useContext(CanvasContext);\n  if (!api) throw new Error(\"usePanZoom must be used inside <PanZoomCanvas>\");\n  return api;\n}\n\nexport type PanZoomCanvasProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> & {\n  children?: React.ReactNode;\n  view?: CanvasView;\n  /** A starting view, or \"fit\" to frame the content on mount. */\n  defaultView?: CanvasView | \"fit\";\n  onViewChange?: (view: CanvasView) => void;\n  minScale?: number;\n  maxScale?: number;\n  /** \"auto\" zooms with a mouse wheel and pans with a trackpad; pinch and ⌘/Ctrl + scroll always zoom. */\n  wheel?: \"auto\" | \"zoom\" | \"pan\";\n  grid?: boolean;\n  /** World units between grid dots. */\n  gridSize?: number;\n  /** The zoom controls in the corner. Pass false to render your own with usePanZoom. */\n  controls?: boolean;\n  /** Padding in px around the content when fitting. */\n  fitPadding?: number;\n  /** Height of the viewport. Pass \"100%\" to fill a sized parent. */\n  height?: number | string;\n};\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/** Pointer velocity in px/s over the last 90ms of samples, or null if there's too little to tell. */\nfunction releaseVelocity(samples: { t: number; x: number; y: number }[]) {\n  const now = performance.now();\n  const recent = samples.filter((p) => now - p.t < 90);\n  if (recent.length < 2) return null;\n  const a = recent[0];\n  const b = recent[recent.length - 1];\n  const dt = Math.max(1, b.t - a.t);\n  return { x: ((b.x - a.x) / dt) * 1000, y: ((b.y - a.y) / dt) * 1000 };\n}\nconst STEP = 1.25;\nconst isEditable = (el: EventTarget | null) => el instanceof HTMLElement && (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));\n\nexport function PanZoomCanvas({\n  children,\n  view,\n  defaultView = { x: 0, y: 0, scale: 1 },\n  onViewChange,\n  minScale = 0.1,\n  maxScale = 4,\n  wheel = \"auto\",\n  grid = true,\n  gridSize = 24,\n  controls = true,\n  fitPadding = 48,\n  height = 420,\n  className,\n  style,\n  onKeyDown,\n  \"aria-label\": ariaLabel = \"Canvas\",\n  ...rest\n}: PanZoomCanvasProps) {\n  const reduce = useReducedMotion();\n  const start = typeof defaultView === \"object\" ? defaultView : { x: 0, y: 0, scale: 1 };\n  const x = useMotionValue(view?.x ?? start.x);\n  const y = useMotionValue(view?.y ?? start.y);\n  const scale = useMotionValue(view?.scale ?? start.scale);\n  // Fitting happens after layout, so the content waits hidden for that first frame.\n  const shown = useMotionValue(defaultView === \"fit\" && !view ? 0 : 1);\n\n  const [pct, setPct] = useState(Math.round((view?.scale ?? start.scale) * 100));\n  const [gesturing, setGesturing] = useState(false);\n  const [panning, setPanning] = useState(false);\n  const [space, setSpace] = useState(false);\n  const [announce, setAnnounce] = useState(\"\");\n  const hintId = useId();\n\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const anim = useRef<AnimationPlaybackControls | null>(null);\n  const glide = useRef(0);\n  // Where an eased zoom is heading, so quick wheel notches build on each other instead of stalling.\n  const aim = useRef<number | null>(null);\n  const hovered = useRef(false);\n  const pointers = useRef(new Map<number, { x: number; y: number }>());\n  const g = useRef({\n    mode: \"none\" as \"none\" | \"pending\" | \"pan\" | \"pinch\",\n    x0: 0,\n    y0: 0,\n    bx: 0,\n    by: 0,\n    d0: 1,\n    m0: { x: 0, y: 0 },\n    s0: 1,\n    samples: [] as { t: number; x: number; y: number }[],\n    moved: false,\n  });\n\n  // ---- view math -------------------------------------------------------------------------\n\n  const stopAll = () => {\n    aim.current = null;\n    anim.current?.stop();\n    cancelAnimationFrame(glide.current);\n  };\n\n  const rect = () => viewportRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, 1, 1);\n  const center = () => {\n    const r = rect();\n    return { x: r.width / 2, y: r.height / 2 };\n  };\n\n  /** Keeps the world point under `focal` (viewport px) fixed while the scale changes. */\n  const setScaleAt = (next: number, focal: { x: number; y: number }, from = { x: x.get(), y: y.get(), scale: scale.get() }) => {\n    const s = clamp(next, minScale, maxScale);\n    x.set(focal.x - (focal.x - from.x) * (s / from.scale));\n    y.set(focal.y - (focal.y - from.y) * (s / from.scale));\n    scale.set(s);\n  };\n\n  /**\n   * Eases to a view. Scale moves in log space so 25%→400% feels as even as 100%→200%, and with a\n   * focal point the world under it stays pinned the whole way, not just at the end.\n   */\n  const tweenTo = (target: CanvasView, duration = 0.32, focal?: { x: number; y: number }) => {\n    stopAll();\n    const from = { x: x.get(), y: y.get(), scale: scale.get() };\n    if (reduce) {\n      x.set(target.x);\n      y.set(target.y);\n      scale.set(target.scale);\n      return;\n    }\n    const l0 = Math.log(from.scale);\n    const l1 = Math.log(target.scale);\n    aim.current = target.scale;\n    anim.current = animate(0, 1, {\n      duration,\n      ease: ease.out,\n      onComplete: () => (aim.current = null),\n      onUpdate: (t) => {\n        const s = Math.exp(l0 + (l1 - l0) * t);\n        if (focal) setScaleAt(s, focal, from);\n        else {\n          x.set(from.x + (target.x - from.x) * t);\n          y.set(from.y + (target.y - from.y) * t);\n          scale.set(s);\n        }\n      },\n    });\n  };\n\n  const zoomTo = (next: number, focal = center(), animated = true) => {\n    const s = clamp(next, minScale, maxScale);\n    const from = { x: x.get(), y: y.get(), scale: scale.get() };\n    const target = { x: focal.x - (focal.x - from.x) * (s / from.scale), y: focal.y - (focal.y - from.y) * (s / from.scale), scale: s };\n    if (animated) tweenTo(target, 0.24, focal);\n    else {\n      stopAll();\n      x.set(target.x);\n      y.set(target.y);\n      scale.set(s);\n    }\n  };\n\n  const fit = (animated = true) => {\n    const content = contentRef.current;\n    if (!content) return;\n    const r = rect();\n    // Read the transform the DOM is actually painted with; the motion values can be a frame ahead of it.\n    const m = new DOMMatrix(getComputedStyle(content).transform === \"none\" ? undefined : getComputedStyle(content).transform);\n    const origin = content.getBoundingClientRect();\n    const s = m.a || 1;\n    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n    for (const el of Array.from(content.children) as HTMLElement[]) {\n      const b = el.getBoundingClientRect();\n      if (!b.width && !b.height) continue;\n      // Screen rect back to world units.\n      minX = Math.min(minX, (b.left - origin.left) / s);\n      minY = Math.min(minY, (b.top - origin.top) / s);\n      maxX = Math.max(maxX, (b.right - origin.left) / s);\n      maxY = Math.max(maxY, (b.bottom - origin.top) / s);\n    }\n    if (!Number.isFinite(minX)) return;\n    const w = maxX - minX;\n    const h = maxY - minY;\n    const pad = Math.min(fitPadding, r.width / 8, r.height / 8);\n    const next = clamp(Math.min((r.width - pad * 2) / w, (r.height - pad * 2) / h, 1), minScale, maxScale);\n    const target = { scale: next, x: r.width / 2 - (minX + w / 2) * next, y: r.height / 2 - (minY + h / 2) * next };\n    if (animated) tweenTo(target, 0.4);\n    else {\n      stopAll();\n      x.set(target.x);\n      y.set(target.y);\n      scale.set(target.scale);\n    }\n  };\n\n  const panBy = (dx: number, dy: number, animated = false) => {\n    if (animated) tweenTo({ x: x.get() + dx, y: y.get() + dy, scale: scale.get() }, 0.24);\n    else {\n      stopAll();\n      x.set(x.get() + dx);\n      y.set(y.get() + dy);\n    }\n  };\n\n  // ---- first frame, controlled view, change events ---------------------------------------\n\n  const mountFit = useEffectEvent(() => {\n    if (defaultView === \"fit\" && !view) {\n      fit(false);\n      if (reduce) shown.set(1);\n      else animate(shown, 1, { duration: 0.2 });\n    }\n  });\n  useEffect(() => {\n    mountFit();\n    return () => {\n      anim.current?.stop();\n      cancelAnimationFrame(glide.current);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!view) return;\n    if (Math.abs(view.x - x.get()) > 0.01) x.set(view.x);\n    if (Math.abs(view.y - y.get()) > 0.01) y.set(view.y);\n    if (Math.abs(view.scale - scale.get()) > 0.0001) scale.set(view.scale);\n  }, [view, x, y, scale]);\n\n  const emit = useRef(0);\n  const report = () => {\n    if (emit.current) return;\n    emit.current = requestAnimationFrame(() => {\n      emit.current = 0;\n      onViewChange?.({ x: x.get(), y: y.get(), scale: scale.get() });\n    });\n  };\n  useEffect(() => () => cancelAnimationFrame(emit.current), []);\n  useMotionValueEvent(x, \"change\", report);\n  useMotionValueEvent(y, \"change\", report);\n  useMotionValueEvent(scale, \"change\", (s) => {\n    report();\n    setPct(Math.round(s * 100));\n  });\n\n  // ---- grid: two dot lattices, the finer fading out as it gets dense ----------------------\n\n  const level = useTransform(() => {\n    let spacing = gridSize * scale.get();\n    while (spacing < 16) spacing *= 2;\n    while (spacing >= 32) spacing /= 2;\n    return spacing;\n  });\n  const fineSize = useTransform(() => `${level.get()}px ${level.get()}px`);\n  const coarseSize = useTransform(() => `${level.get() * 2}px ${level.get() * 2}px`);\n  const gridPos = useTransform(() => `${x.get()}px ${y.get()}px`);\n  const fineOpacity = useTransform(() => clamp((level.get() - 16) / 12, 0, 1));\n\n  // ---- pointer: drag the background (or anything with Space held), pinch with two fingers --\n\n  function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {\n    const el = e.target as HTMLElement;\n    const onItem = !!el.closest(\"[data-canvas-item]\") && !space;\n    const middle = e.pointerType === \"mouse\" && e.button === 1;\n    if (e.pointerType === \"mouse\" && e.button !== 0 && !middle) return;\n    if (el.closest(\"[data-canvas-controls]\")) return;\n    // A mouse on an item is working with the item. A finger on an item may still pan once it moves.\n    if (onItem && e.pointerType === \"mouse\" && !middle) return;\n    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n    stopAll();\n    const s = g.current;\n    if (pointers.current.size === 2) {\n      const [a, b] = Array.from(pointers.current.values());\n      const r = rect();\n      s.mode = \"pinch\";\n      s.d0 = Math.hypot(a.x - b.x, a.y - b.y) || 1;\n      s.m0 = { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top };\n      s.s0 = scale.get();\n      s.bx = x.get();\n      s.by = y.get();\n      s.moved = true;\n      e.currentTarget.setPointerCapture(e.pointerId);\n      setGesturing(true);\n      setPanning(false);\n      return;\n    }\n    if (pointers.current.size > 2) return;\n    Object.assign(s, { mode: \"pending\", x0: e.clientX, y0: e.clientY, bx: x.get(), by: y.get(), samples: [], moved: false });\n    if (middle || space || !onItem) {\n      // Middle-button autoscroll would fight the pan.\n      if (middle) e.preventDefault();\n      s.mode = \"pan\";\n      e.currentTarget.setPointerCapture(e.pointerId);\n      setPanning(true);\n    }\n  }\n\n  function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {\n    if (!pointers.current.has(e.pointerId)) return;\n    pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });\n    const s = g.current;\n    if (s.mode === \"pinch\" && pointers.current.size === 2) {\n      const [a, b] = Array.from(pointers.current.values());\n      const r = rect();\n      const d = Math.hypot(a.x - b.x, a.y - b.y);\n      const m = { x: (a.x + b.x) / 2 - r.left, y: (a.y + b.y) / 2 - r.top };\n      const next = clamp(s.s0 * (d / s.d0), minScale, maxScale);\n      // The world point that was under the fingers stays under them, wherever they go.\n      x.set(m.x - (s.m0.x - s.bx) * (next / s.s0));\n      y.set(m.y - (s.m0.y - s.by) * (next / s.s0));\n      scale.set(next);\n      return;\n    }\n    const dx = e.clientX - s.x0;\n    const dy = e.clientY - s.y0;\n    if (s.mode === \"pending\") {\n      if (Math.hypot(dx, dy) < 4) return;\n      s.mode = \"pan\";\n      e.currentTarget.setPointerCapture(e.pointerId);\n      setPanning(true);\n    }\n    if (s.mode !== \"pan\") return;\n    if (!s.moved && Math.hypot(dx, dy) >= 4) {\n      s.moved = true;\n      setGesturing(true);\n    }\n    x.set(s.bx + dx);\n    y.set(s.by + dy);\n    s.samples.push({ t: performance.now(), x: e.clientX, y: e.clientY });\n    if (s.samples.length > 8) s.samples.shift();\n  }\n\n  function onPointerEnd(e: React.PointerEvent<HTMLDivElement>) {\n    if (!pointers.current.has(e.pointerId)) return;\n    pointers.current.delete(e.pointerId);\n    const s = g.current;\n    if (s.mode === \"pinch\") {\n      // One finger left: carry on panning with the other from where it is.\n      const rest = Array.from(pointers.current.entries())[0];\n      if (rest) Object.assign(s, { mode: \"pan\", x0: rest[1].x, y0: rest[1].y, bx: x.get(), by: y.get(), samples: [] });\n      else end();\n      return;\n    }\n    if (s.mode === \"pan\" && s.moved && e.type !== \"pointercancel\" && !reduce) {\n      const v = releaseVelocity(s.samples);\n      if (v) coast(v.x, v.y);\n    }\n    end();\n  }\n\n  function end() {\n    g.current.mode = \"none\";\n    setPanning(false);\n    setGesturing(false);\n  }\n\n  /** Momentum after a flick: exponential decay, the way a scroll view coasts. */\n  function coast(vx: number, vy: number) {\n    if (Math.hypot(vx, vy) < 120) return;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const dt = Math.min(32, now - last) / 1000;\n      last = now;\n      const k = Math.exp(-dt / 0.28);\n      vx *= k;\n      vy *= k;\n      x.set(x.get() + vx * dt);\n      y.set(y.get() + vy * dt);\n      if (Math.hypot(vx, vy) > 12) glide.current = requestAnimationFrame(tick);\n    };\n    glide.current = requestAnimationFrame(tick);\n  }\n\n  // ---- wheel ------------------------------------------------------------------------------\n\n  const wheelSettle = useRef(0);\n  const onWheel = useEffectEvent((e: WheelEvent) => {\n    e.preventDefault();\n    const r = rect();\n    const focal = { x: e.clientX - r.left, y: e.clientY - r.top };\n    const pinch = e.ctrlKey || e.metaKey;\n    // A mouse wheel moves in whole lines or big even steps with no sideways component.\n    const mouseWheel = e.deltaMode !== 0 || (e.deltaX === 0 && Math.abs(e.deltaY) >= 40 && Number.isInteger(e.deltaY));\n    const zoom = pinch || wheel === \"zoom\" || (wheel === \"auto\" && mouseWheel);\n    setGesturing(true);\n    window.clearTimeout(wheelSettle.current);\n    wheelSettle.current = window.setTimeout(() => setGesturing(false), 160);\n    if (!zoom) {\n      stopAll();\n      x.set(x.get() - e.deltaX);\n      y.set(y.get() - e.deltaY);\n      return;\n    }\n    const lines = e.deltaMode === 1 ? 16 : 1;\n    const factor = Math.exp(-e.deltaY * lines * (pinch ? 0.01 : 0.0022));\n    if (pinch || reduce) {\n      stopAll();\n      setScaleAt(scale.get() * factor, focal);\n    } else {\n      // Notched wheels step, so each notch eases in instead of jumping.\n      zoomTo((aim.current ?? scale.get()) * factor, focal, true);\n    }\n  });\n\n  useEffect(() => {\n    const el = viewportRef.current;\n    if (!el) return;\n    const h = (e: WheelEvent) => onWheel(e);\n    el.addEventListener(\"wheel\", h, { passive: false });\n    // Safari's trackpad pinch arrives as gesture events; the wheel path covers everyone else.\n    const block = (e: Event) => e.preventDefault();\n    el.addEventListener(\"gesturestart\", block);\n    return () => {\n      el.removeEventListener(\"wheel\", h);\n      el.removeEventListener(\"gesturestart\", block);\n      window.clearTimeout(wheelSettle.current);\n    };\n  }, []);\n\n  // ---- keyboard ---------------------------------------------------------------------------\n\n  const onSpace = useEffectEvent((e: KeyboardEvent, down: boolean) => {\n    if (e.code !== \"Space\") return;\n    const vp = viewportRef.current;\n    const focus = document.activeElement;\n    // A focused button or link inside the canvas keeps Space for itself.\n    if (vp && focus && focus !== vp && vp.contains(focus) && !space) return;\n    const inside = hovered.current || vp?.contains(focus);\n    if (down) {\n      if (!inside || isEditable(e.target) || e.repeat) {\n        if (inside && e.repeat && space) e.preventDefault();\n        return;\n      }\n      e.preventDefault();\n      setSpace(true);\n    } else if (space) {\n      e.preventDefault();\n      setSpace(false);\n    }\n  });\n\n  useEffect(() => {\n    const down = (e: KeyboardEvent) => onSpace(e, true);\n    const up = (e: KeyboardEvent) => onSpace(e, false);\n    const blur = () => setSpace(false);\n    window.addEventListener(\"keydown\", down);\n    window.addEventListener(\"keyup\", up);\n    window.addEventListener(\"blur\", blur);\n    return () => {\n      window.removeEventListener(\"keydown\", down);\n      window.removeEventListener(\"keyup\", up);\n      window.removeEventListener(\"blur\", blur);\n    };\n  }, []);\n\n  const say = (s: number) => setAnnounce(`Zoom ${Math.round(clamp(s, minScale, maxScale) * 100)}%`);\n\n  const api: Api = {\n    zoomIn: () => {\n      const next = (aim.current ?? scale.get()) * STEP;\n      zoomTo(next);\n      say(next);\n    },\n    zoomOut: () => {\n      const next = (aim.current ?? scale.get()) / STEP;\n      zoomTo(next);\n      say(next);\n    },\n    reset: () => {\n      zoomTo(1);\n      say(1);\n    },\n    fit: (animated = true) => {\n      fit(animated);\n      setAnnounce(\"Zoomed to fit\");\n    },\n    zoomTo,\n    panBy,\n    scale: pct / 100,\n    minScale,\n    maxScale,\n  };\n\n  function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n    onKeyDown?.(e);\n    if (e.defaultPrevented || isEditable(e.target) || e.altKey) return;\n    const mod = e.metaKey || e.ctrlKey;\n    if (e.key === \"+\" || e.key === \"=\") api.zoomIn();\n    else if (e.key === \"-\" || e.key === \"_\") api.zoomOut();\n    else if (e.key === \"0\" && !mod) api.reset();\n    else if (e.key === \"1\" && !mod) api.fit();\n    else if (e.target === e.currentTarget && e.key.startsWith(\"Arrow\")) {\n      // Arrows pan the view itself; they stay out of the way once focus is on an item.\n      const d = e.shiftKey ? 200 : 40;\n      panBy(e.key === \"ArrowLeft\" ? d : e.key === \"ArrowRight\" ? -d : 0, e.key === \"ArrowUp\" ? d : e.key === \"ArrowDown\" ? -d : 0);\n    } else return;\n    // The canvas owns these keys while it has focus; nothing further up should also act on them.\n    e.preventDefault();\n    e.stopPropagation();\n  }\n\n  // Tabbing to an item that's out of view brings it into view.\n  function handleFocus(e: React.FocusEvent<HTMLDivElement>) {\n    const t = e.target as HTMLElement;\n    if (t === e.currentTarget || !t.closest(\"[data-canvas-item]\") || !t.matches(\":focus-visible\")) return;\n    const r = rect();\n    const b = t.getBoundingClientRect();\n    const m = 24;\n    let dx = 0;\n    let dy = 0;\n    if (b.left < r.left + m) dx = r.left + m - b.left;\n    else if (b.right > r.right - m) dx = r.right - m - b.right;\n    if (b.top < r.top + m) dy = r.top + m - b.top;\n    else if (b.bottom > r.bottom - m - 48) dy = r.bottom - m - 48 - b.bottom;\n    if (dx || dy) panBy(dx, dy, true);\n  }\n\n  return (\n    <CanvasContext.Provider value={api}>\n      <div\n        ref={viewportRef}\n        tabIndex={0}\n        role=\"region\"\n        aria-roledescription=\"canvas\"\n        aria-label={ariaLabel}\n        aria-describedby={hintId}\n        data-panning={panning || undefined}\n        data-space={space || undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerEnd}\n        onPointerCancel={onPointerEnd}\n        onPointerEnter={() => (hovered.current = true)}\n        onPointerLeave={() => (hovered.current = false)}\n        onClickCapture={(e) => {\n          // A pan that started on an item ends without clicking it.\n          if (g.current.moved && !(e.target as HTMLElement).closest(\"[data-canvas-controls]\")) {\n            e.preventDefault();\n            e.stopPropagation();\n          }\n          g.current.moved = false;\n        }}\n        onAuxClick={(e) => e.button === 1 && e.preventDefault()}\n        onKeyDown={handleKeyDown}\n        onFocus={handleFocus}\n        style={{ ...style, height }}\n        className={cn(\n          \"relative isolate touch-none select-none overflow-hidden overscroll-contain rounded-xl border border-line bg-frame outline-none\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n          panning ? \"cursor-grabbing [&_*]:cursor-grabbing\" : space ? \"cursor-grab [&_*]:cursor-grab\" : \"cursor-grab\",\n          className,\n        )}\n        {...rest}\n      >\n        {grid && (\n          <>\n            <motion.div\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0\"\n              style={{ backgroundImage: \"radial-gradient(circle, var(--fg-4) 1px, transparent 1.25px)\", backgroundSize: coarseSize, backgroundPosition: gridPos, opacity: 0.7 }}\n            />\n            <motion.div\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0\"\n              style={{ backgroundImage: \"radial-gradient(circle, var(--line-2) 1px, transparent 1.25px)\", backgroundSize: fineSize, backgroundPosition: gridPos, opacity: fineOpacity }}\n            />\n          </>\n        )}\n\n        <motion.div\n          ref={contentRef}\n          data-canvas-content=\"\"\n          style={{ x, y, scale, opacity: shown, transformOrigin: \"0 0\" }}\n          className={cn(\"absolute left-0 top-0 h-0 w-0 cursor-auto\", space && \"pointer-events-none\")}\n        >\n          {children}\n        </motion.div>\n\n        {controls && <Controls gesturing={gesturing} />}\n\n        <p id={hintId} className=\"sr-only\">\n          Drag the background or hold Space and drag to pan. Use plus and minus to zoom, 0 for 100%, 1 to fit, and the arrow keys to move.\n        </p>\n        <p role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announce}\n        </p>\n      </div>\n    </CanvasContext.Provider>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Items and controls\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CanvasItemProps = React.ComponentProps<\"div\"> & {\n  /** Position in world units. */\n  x: number;\n  y: number;\n};\n\n/** Anything placed on the canvas at world coordinates. Pointer presses on it don't pan with a mouse. */\nexport function CanvasItem({ x, y, className, style, ...rest }: CanvasItemProps) {\n  return <div data-canvas-item=\"\" style={{ ...style, left: x, top: y }} className={cn(\"absolute\", className)} {...rest} />;\n}\n\nfunction Controls({ gesturing }: { gesturing: boolean }) {\n  const api = usePanZoom();\n  const pct = Math.round(api.scale * 100);\n  return (\n    <Tooltip.Provider delay={500}>\n      <div\n        data-canvas-controls=\"\"\n        role=\"toolbar\"\n        aria-label=\"Zoom\"\n        className=\"absolute bottom-3 left-3 z-10 flex cursor-auto items-center gap-0.5 rounded-lg border border-line-2 bg-raised p-0.5 shadow-pop\"\n      >\n        <ControlButton label=\"Zoom out\" keys=\"−\" disabled={api.scale <= api.minScale + 1e-6} onClick={api.zoomOut}>\n          <Minus />\n        </ControlButton>\n        <ControlButton label=\"Reset to 100%\" keys=\"0\" wide onClick={api.reset}>\n          {/* Rolls on discrete steps; tracks the pinch frame by frame without animating. */}\n          <NumberFlow value={pct} suffix=\"%\" animated={!gesturing} className=\"tabular text-[12px] font-medium\" />\n        </ControlButton>\n        <ControlButton label=\"Zoom in\" keys=\"+\" disabled={api.scale >= api.maxScale - 1e-6} onClick={api.zoomIn}>\n          <Plus />\n        </ControlButton>\n        <span aria-hidden className=\"mx-0.5 h-4 w-px bg-line-2\" />\n        <ControlButton label=\"Zoom to fit\" keys=\"1\" onClick={() => api.fit()}>\n          <Maximize />\n        </ControlButton>\n      </div>\n    </Tooltip.Provider>\n  );\n}\n\nfunction ControlButton({ label, keys, wide, disabled, onClick, children }: { label: string; keys: string; wide?: boolean; disabled?: boolean; onClick: () => void; children: React.ReactNode }) {\n  return (\n    <Tooltip.Root>\n      <Tooltip.Trigger\n        render={\n          <button\n            type=\"button\"\n            aria-label={label}\n            disabled={disabled}\n            onClick={onClick}\n            className={cn(\n              \"grid h-7 place-items-center rounded-md text-fg-2 outline-none\",\n              \"transition-[background-color,color,scale] duration-150 hover:bg-hover hover:text-fg active:scale-[0.94] active:duration-75\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n              \"disabled:pointer-events-none disabled:opacity-40\",\n              // Grows the hit area to 44px on touch without changing the drawing.\n              \"relative before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden\",\n              wide ? \"w-[52px]\" : \"w-7\",\n            )}\n          />\n        }\n      >\n        {children}\n      </Tooltip.Trigger>\n      <Tooltip.Portal>\n        <Tooltip.Positioner side=\"top\" sideOffset={8} collisionPadding={8} className=\"z-(--z-tooltip)\">\n          <Tooltip.Popup\n            className={cn(\n              \"flex origin-(--transform-origin) items-center gap-2 rounded-lg border border-line-2 bg-raised py-[3px] pl-2 pr-1 text-[12px] leading-5 text-fg shadow-pop\",\n              \"transition-[opacity,scale] duration-150 ease-out-expo data-ending-style:duration-100\",\n              \"data-starting-style:scale-96 data-starting-style:opacity-0 data-ending-style:opacity-0 data-instant:transition-none\",\n            )}\n          >\n            {label}\n            <kbd className=\"inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[5px] border border-line-2 bg-frame px-1 font-mono text-[10.5px] leading-none text-fg-2\">{keys}</kbd>\n          </Tooltip.Popup>\n        </Tooltip.Positioner>\n      </Tooltip.Portal>\n    </Tooltip.Root>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/pan-zoom-canvas"
}
