{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "pan-zoom-canvas",
  "type": "registry:ui",
  "title": "Pan & Zoom Canvas",
  "description": "An infinite-feeling canvas: drag to pan with inertia, wheel or pinch to zoom around the cursor, with controls and keys.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/pan-zoom-canvas.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionTemplate, useMotionValue, useTransform, type AnimationPlaybackControls } from \"motion/react\";\n\nexport interface PanZoomTransform {\n  x: number;\n  y: number;\n  zoom: number;\n}\n\nexport interface PanZoomCanvasHandle {\n  /** Zoom to an absolute level around a point in viewport px (defaults to the viewport centre). */\n  zoomTo: (zoom: number, origin?: { x: number; y: number }) => void;\n  zoomIn: () => void;\n  zoomOut: () => void;\n  /** Fit every child into the viewport (with `fitPadding`). */\n  fitToContent: () => void;\n  /** Return to the initial view (fitted when `initialFit`, otherwise 100% at the origin). */\n  reset: () => void;\n  getTransform: () => PanZoomTransform;\n}\n\nexport interface PanZoomCanvasProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"onDrag\" | \"onDragStart\" | \"onDragEnd\" | \"onAnimationStart\" | \"onAnimationEnd\"> {\n  /** Content placed in canvas coordinates — use `CanvasItem` or absolutely positioned children. */\n  children?: React.ReactNode;\n  minZoom?: number;\n  maxZoom?: number;\n  /** Multiplier for the zoom buttons and the +/− keys. */\n  zoomStep?: number;\n  /** What the mouse wheel does: zoom around the cursor, or pan (pinch / Ctrl + wheel still zooms). */\n  wheelMode?: \"zoom\" | \"pan\";\n  /** Keep gliding after a drag is released. */\n  inertia?: boolean;\n  /** Show the dot grid. */\n  showGrid?: boolean;\n  /** Grid spacing at 100% zoom, in canvas px. */\n  gridSize?: number;\n  /** Show the zoom controls. */\n  showControls?: boolean;\n  /** Fit the content into view on mount (and on `reset`). */\n  initialFit?: boolean;\n  /** Padding kept around the content when fitting, in px. */\n  fitPadding?: number;\n  /** Accent for the focus ring and the zoom readout. */\n  accent?: string;\n  /** Accessible name of the canvas region. */\n  label?: string;\n  /** Called on every transform change (pan, zoom, animation frames). */\n  onTransformChange?: (transform: PanZoomTransform) => void;\n}\n\nexport interface CanvasItemProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Left edge in canvas px. */\n  x: number;\n  /** Top edge in canvas px. */\n  y: number;\n}\n\n/** Places its children at canvas coordinates inside a `PanZoomCanvas`. */\nexport function CanvasItem({ x, y, ...props }: CanvasItemProps) {\n  // The outer box only positions (max-content, so text never wraps against the zero-size layer);\n  // className, style and the rest go to the inner element.\n  return (\n    <div className=\"absolute w-max\" style={{ left: x, top: y }}>\n      <div {...props} />\n    </div>\n  );\n}\n\nfunction subscribeReducedMotion(cb: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", cb);\n  return () => mq.removeEventListener(\"change\", cb);\n}\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\nconst VIEW_SPRING = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.9 } as const;\nconst GLIDE_TIME = 0.3; // s, decay time constant of the inertial glide\nconst GLIDE_POWER = 0.8; // fraction of the release velocity carried into the glide\nconst PAN_STEP = 48; // px per arrow key press (×4 with Shift)\n/** Elements that keep their own pointer behaviour instead of starting a pan. */\nconst INTERACTIVE = \"button, a[href], input, select, textarea, [contenteditable='true'], [data-no-pan]\";\n\n/**\n * An infinite-feeling canvas viewport. Drag to pan (with inertia), wheel or pinch to zoom around\n * the pointer, zoom controls with a live readout, and keyboard support when focused (arrows pan,\n * + / − zoom, 0 resets, F fits). Children are placed in canvas coordinates. Exposes an imperative\n * handle: zoomTo, zoomIn, zoomOut, fitToContent, reset, getTransform.\n */\nexport const PanZoomCanvas = React.forwardRef<PanZoomCanvasHandle, PanZoomCanvasProps>(function PanZoomCanvas(\n  {\n    children,\n    minZoom = 0.25,\n    maxZoom = 3,\n    zoomStep = 1.25,\n    wheelMode = \"zoom\",\n    inertia = true,\n    showGrid = true,\n    gridSize = 24,\n    showControls = true,\n    initialFit = true,\n    fitPadding = 48,\n    accent = \"#FF7448\",\n    label = \"Canvas\",\n    onTransformChange,\n    className = \"\",\n    style,\n    onKeyDown,\n    ...props\n  },\n  ref,\n) {\n  const uid = React.useId();\n  const reduceMotion = usePrefersReducedMotion();\n  const viewportRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const readoutRef = React.useRef<HTMLSpanElement>(null);\n  const running = React.useRef<AnimationPlaybackControls[]>([]);\n  /** Where the running spring is heading, so repeated zoom steps compound from the target. */\n  const target = React.useRef<PanZoomTransform | null>(null);\n  const glideFrame = React.useRef(0);\n  const pointers = React.useRef(new Map<number, { x: number; y: number }>());\n  const gesture = React.useRef({ lastX: 0, lastY: 0, distance: 0, samples: [] as { x: number; y: number; t: number }[] });\n  const [dragging, setDragging] = React.useState(false);\n  const zoomOutRef = React.useRef<HTMLButtonElement>(null);\n  const zoomInRef = React.useRef<HTMLButtonElement>(null);\n  const [announcement, setAnnouncement] = React.useState(\"\");\n\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const zoom = useMotionValue(1);\n\n  // Dot grid that pans and scales with the content.\n  const gridStep = useTransform(() => `${Math.round(gridSize * zoom.get() * 100) / 100}px`);\n  const backgroundSize = useMotionTemplate`${gridStep} ${gridStep}`;\n  const backgroundPosition = useMotionTemplate`${x}px ${y}px`;\n\n  /* ----------------------------- transform helpers ----------------------------- */\n\n  const stop = () => {\n    cancelAnimationFrame(glideFrame.current);\n    for (const a of running.current) a.stop();\n    running.current = [];\n    target.current = null;\n  };\n\n  /** The settled view: the spring's target while one runs, else the current values. */\n  const base = (): PanZoomTransform => target.current ?? { x: x.get(), y: y.get(), zoom: zoom.get() };\n\n  const viewportSize = () => {\n    const el = viewportRef.current;\n    return { width: el?.clientWidth ?? 0, height: el?.clientHeight ?? 0 };\n  };\n\n  /** Viewport-local pointer coordinates, compensating for scaled ancestors. */\n  const localPoint = (clientX: number, clientY: number) => {\n    const el = viewportRef.current;\n    if (!el) return { x: 0, y: 0 };\n    const rect = el.getBoundingClientRect();\n    const scale = el.offsetWidth ? rect.width / el.offsetWidth || 1 : 1;\n    return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };\n  };\n\n  /** Applies a transform immediately (gestures) or on a spring (buttons, keys, fit). */\n  const apply = (next: PanZoomTransform, animated: boolean) => {\n    stop();\n    if (!animated || reduceMotion) {\n      x.set(next.x);\n      y.set(next.y);\n      zoom.set(next.zoom);\n      return;\n    }\n    target.current = next;\n    const done = () => {\n      if (target.current === next) target.current = null;\n    };\n    running.current = [animate(x, next.x, VIEW_SPRING), animate(y, next.y, VIEW_SPRING), animate(zoom, next.zoom, { ...VIEW_SPRING, onComplete: done })];\n  };\n\n  /** The transform that zooms to `target` while keeping the viewport point `origin` fixed. */\n  const zoomAround = (level: number, origin: { x: number; y: number }, from: PanZoomTransform = { x: x.get(), y: y.get(), zoom: zoom.get() }): PanZoomTransform => {\n    const next = clamp(level, minZoom, maxZoom);\n    const k = next / from.zoom;\n    return { x: origin.x - (origin.x - from.x) * k, y: origin.y - (origin.y - from.y) * k, zoom: next };\n  };\n\n  const center = () => {\n    const { width, height } = viewportSize();\n    return { x: width / 2, y: height / 2 };\n  };\n\n  /** Bounding box of the children in canvas coordinates (offsets are unaffected by the transform). */\n  const contentBounds = () => {\n    const content = contentRef.current;\n    if (!content || content.children.length === 0) return null;\n    let minX = Infinity;\n    let minY = Infinity;\n    let maxX = -Infinity;\n    let maxY = -Infinity;\n    for (const child of Array.from(content.children)) {\n      if (!(child instanceof HTMLElement)) continue;\n      minX = Math.min(minX, child.offsetLeft);\n      minY = Math.min(minY, child.offsetTop);\n      maxX = Math.max(maxX, child.offsetLeft + child.offsetWidth);\n      maxY = Math.max(maxY, child.offsetTop + child.offsetHeight);\n    }\n    return Number.isFinite(minX) ? { x: minX, y: minY, width: maxX - minX, height: maxY - minY } : null;\n  };\n\n  const fitTransform = (): PanZoomTransform => {\n    const bounds = contentBounds();\n    const { width, height } = viewportSize();\n    if (!bounds || !width || !height) return { x: 0, y: 0, zoom: 1 };\n    const z = clamp(\n      Math.min((width - fitPadding * 2) / Math.max(1, bounds.width), (height - fitPadding * 2) / Math.max(1, bounds.height)),\n      minZoom,\n      Math.min(maxZoom, 1.5),\n    );\n    return { x: (width - bounds.width * z) / 2 - bounds.x * z, y: (height - bounds.height * z) / 2 - bounds.y * z, zoom: z };\n  };\n\n  const initialTransform = () => (initialFit ? fitTransform() : { x: 0, y: 0, zoom: 1 });\n  const percent = (z: number) => `${Math.round(z * 100)}%`;\n\n  const actions = {\n    zoomTo: (z: number, origin?: { x: number; y: number }) => {\n      const next = zoomAround(z, origin ?? center(), base());\n      apply(next, true);\n      setAnnouncement(`Zoom ${percent(next.zoom)}`);\n    },\n    zoomIn: () => actions.zoomTo(base().zoom * zoomStep),\n    zoomOut: () => actions.zoomTo(base().zoom / zoomStep),\n    fitToContent: () => {\n      const next = fitTransform();\n      apply(next, true);\n      setAnnouncement(`Fitted to content, zoom ${percent(next.zoom)}`);\n    },\n    reset: () => {\n      const next = initialTransform();\n      apply(next, true);\n      setAnnouncement(`View reset, zoom ${percent(next.zoom)}`);\n    },\n    panBy: (dx: number, dy: number) => {\n      const from = base();\n      apply({ x: from.x + dx, y: from.y + dy, zoom: from.zoom }, true);\n    },\n  };\n\n  React.useImperativeHandle(ref, () => ({\n    zoomTo: actions.zoomTo,\n    zoomIn: actions.zoomIn,\n    zoomOut: actions.zoomOut,\n    fitToContent: actions.fitToContent,\n    reset: actions.reset,\n    getTransform: () => ({ x: x.get(), y: y.get(), zoom: zoom.get() }),\n  }));\n\n  // Live readout and button states, written to the DOM so zooming never re-renders the tree.\n  const syncReadout = (z: number) => {\n    if (readoutRef.current) readoutRef.current.textContent = percent(z);\n    // aria-disabled (not disabled) so a focused button keeps focus when it reaches a limit.\n    zoomOutRef.current?.setAttribute(\"aria-disabled\", String(z <= minZoom + 1e-3));\n    zoomInRef.current?.setAttribute(\"aria-disabled\", String(z >= maxZoom - 1e-3));\n  };\n\n  const notify = () => onTransformChange?.({ x: x.get(), y: y.get(), zoom: zoom.get() });\n  const onZoomChange = React.useEffectEvent((z: number) => {\n    syncReadout(z);\n    notify();\n  });\n  const onPanChange = React.useEffectEvent(() => notify());\n  const initialise = React.useEffectEvent(() => {\n    const next = initialTransform();\n    x.set(next.x);\n    y.set(next.y);\n    zoom.set(next.zoom);\n  });\n  // Subscribe first, then set the initial view before paint so the content never jumps.\n  React.useLayoutEffect(() => {\n    const unsubscribe = [zoom.on(\"change\", onZoomChange), x.on(\"change\", onPanChange), y.on(\"change\", onPanChange)];\n    initialise();\n    return () => unsubscribe.forEach((off) => off());\n  }, [x, y, zoom]);\n\n  // Clamp the current view when the limits change.\n  const clampToLimits = React.useEffectEvent(() => {\n    const z = zoom.get();\n    if (z < minZoom || z > maxZoom) apply(zoomAround(z, center()), false);\n    else syncReadout(z);\n  });\n  React.useEffect(() => clampToLimits(), [minZoom, maxZoom]);\n\n  /* ---------------------------------- wheel ---------------------------------- */\n\n  // A non-passive listener is required to keep the page from scrolling while zooming the canvas.\n  const onWheel = React.useEffectEvent((event: WheelEvent) => {\n    event.preventDefault();\n    stop();\n    const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewportSize().height : 1;\n    const dx = event.deltaX * unit;\n    const dy = event.deltaY * unit;\n    // Trackpad pinch arrives as Ctrl + wheel.\n    if (wheelMode === \"zoom\" || event.ctrlKey || event.metaKey) {\n      const factor = Math.exp(-dy * (event.ctrlKey ? 0.01 : 0.0015));\n      apply(zoomAround(zoom.get() * factor, localPoint(event.clientX, event.clientY)), false);\n    } else {\n      apply({ x: x.get() - dx, y: y.get() - dy, zoom: zoom.get() }, false);\n    }\n  });\n  React.useEffect(() => {\n    const el = viewportRef.current;\n    if (!el) return;\n    const handler = (event: WheelEvent) => onWheel(event);\n    el.addEventListener(\"wheel\", handler, { passive: false });\n    return () => el.removeEventListener(\"wheel\", handler);\n  }, []);\n\n  // Stop any running glide or spring on unmount.\n  React.useEffect(() => {\n    const list = running;\n    const frame = glideFrame;\n    return () => {\n      cancelAnimationFrame(frame.current);\n      list.current.forEach((a) => a.stop());\n    };\n  }, []);\n\n  /* -------------------------------- pointers --------------------------------- */\n\n  const pinchInfo = () => {\n    const [a, b] = Array.from(pointers.current.values());\n    return { mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }, distance: Math.hypot(a.x - b.x, a.y - b.y) };\n  };\n\n  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType === \"mouse\" && event.button !== 0 && event.button !== 1) return;\n    if ((event.target as Element).closest(INTERACTIVE)) return;\n    stop();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const p = localPoint(event.clientX, event.clientY);\n    pointers.current.set(event.pointerId, p);\n    const g = gesture.current;\n    if (pointers.current.size === 2) {\n      const { mid, distance } = pinchInfo();\n      g.lastX = mid.x;\n      g.lastY = mid.y;\n      g.distance = distance;\n    } else {\n      g.lastX = p.x;\n      g.lastY = p.y;\n    }\n    g.samples = [{ x: p.x, y: p.y, t: event.timeStamp }];\n    setDragging(true);\n  };\n\n  const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.has(event.pointerId)) return;\n    const p = localPoint(event.clientX, event.clientY);\n    pointers.current.set(event.pointerId, p);\n    const g = gesture.current;\n    if (pointers.current.size >= 2) {\n      // Pinch: zoom around the midpoint and pan with it.\n      const { mid, distance } = pinchInfo();\n      const zoomed = zoomAround(zoom.get() * (g.distance ? distance / g.distance : 1), mid);\n      x.set(zoomed.x + mid.x - g.lastX);\n      y.set(zoomed.y + mid.y - g.lastY);\n      zoom.set(zoomed.zoom);\n      g.lastX = mid.x;\n      g.lastY = mid.y;\n      g.distance = distance;\n      g.samples = [];\n      return;\n    }\n    x.set(x.get() + p.x - g.lastX);\n    y.set(y.get() + p.y - g.lastY);\n    g.lastX = p.x;\n    g.lastY = p.y;\n    g.samples.push({ x: p.x, y: p.y, t: event.timeStamp });\n    if (g.samples.length > 6) g.samples.shift();\n  };\n\n  const endPointer = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (!pointers.current.delete(event.pointerId)) return;\n    const g = gesture.current;\n    if (pointers.current.size === 1) {\n      // Pinch ended with one finger left: continue panning from it.\n      const [rest] = Array.from(pointers.current.values());\n      g.lastX = rest.x;\n      g.lastY = rest.y;\n      g.samples = [];\n      return;\n    }\n    if (pointers.current.size > 0) return;\n    setDragging(false);\n    if (!inertia || reduceMotion || g.samples.length < 2) return;\n    const first = g.samples[0];\n    const last = g.samples[g.samples.length - 1];\n    const dt = last.t - first.t;\n    // Ignore stale samples (pointer held still before release).\n    if (dt <= 0 || event.timeStamp - last.t > 80) return;\n    const vx = ((last.x - first.x) / dt) * 1000;\n    const vy = ((last.y - first.y) / dt) * 1000;\n    glide(vx * GLIDE_POWER, vy * GLIDE_POWER);\n  };\n\n  /** Exponential decay of the release velocity (px/s), one frame at a time. */\n  const glide = (vx: number, vy: number) => {\n    let last = performance.now();\n    const step = (now: number) => {\n      const dt = Math.min(64, now - last) / 1000;\n      last = now;\n      const decay = Math.exp(-dt / GLIDE_TIME);\n      vx *= decay;\n      vy *= decay;\n      x.set(x.get() + vx * dt);\n      y.set(y.get() + vy * dt);\n      if (Math.hypot(vx, vy) > 10 && !document.hidden) glideFrame.current = requestAnimationFrame(step);\n    };\n    glideFrame.current = requestAnimationFrame(step);\n  };\n\n  /* --------------------------------- keyboard -------------------------------- */\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    // Only when the canvas itself is focused, so inputs inside keep their keys.\n    if (event.defaultPrevented || event.target !== event.currentTarget || event.altKey || event.ctrlKey || event.metaKey) return;\n    const step = event.shiftKey ? PAN_STEP * 4 : PAN_STEP;\n    const keys: Record<string, () => void> = {\n      ArrowLeft: () => actions.panBy(step, 0),\n      ArrowRight: () => actions.panBy(-step, 0),\n      ArrowUp: () => actions.panBy(0, step),\n      ArrowDown: () => actions.panBy(0, -step),\n      \"+\": actions.zoomIn,\n      \"=\": actions.zoomIn,\n      \"-\": actions.zoomOut,\n      _: actions.zoomOut,\n      \"0\": actions.reset,\n      f: actions.fitToContent,\n      F: actions.fitToContent,\n    };\n    const run = keys[event.key];\n    if (!run) return;\n    event.preventDefault();\n    run();\n  };\n\n  const hintId = `${uid}-hint`;\n  const button =\n    \"grid size-8 place-items-center rounded-md text-zinc-600 transition-colors outline-hidden hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--pz-accent)] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:hover:bg-transparent dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-zinc-100\";\n\n  return (\n    <div\n      className={`relative h-full w-full overflow-hidden ${className}`}\n      style={{ [\"--pz-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <motion.div\n        ref={viewportRef}\n        role=\"region\"\n        aria-roledescription=\"pan and zoom canvas\"\n        aria-label={label}\n        aria-describedby={hintId}\n        tabIndex={0}\n        className={`absolute inset-0 touch-none select-none overflow-hidden outline-hidden [--pz-dot:rgb(24_24_27/0.16)] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-solid focus-visible:outline-[var(--pz-accent)] dark:[--pz-dot:rgb(255_255_255/0.13)] ${dragging ? \"cursor-grabbing\" : \"cursor-grab\"}`}\n        style={\n          showGrid\n            ? { backgroundImage: \"radial-gradient(circle, var(--pz-dot) 1px, transparent 1.3px)\", backgroundSize, backgroundPosition }\n            : undefined\n        }\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={endPointer}\n        onPointerCancel={endPointer}\n        onKeyDown={handleKeyDown}\n      >\n        <motion.div ref={contentRef} className=\"absolute left-0 top-0 origin-top-left\" style={{ x, y, scale: zoom }}>\n          {children}\n        </motion.div>\n      </motion.div>\n\n      <span id={hintId} className=\"sr-only\">\n        Drag to pan, scroll or pinch to zoom. When focused: arrow keys pan, plus and minus zoom, 0 resets the view, F fits the content.\n      </span>\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </span>\n\n      {showControls ? (\n        <div\n          role=\"toolbar\"\n          aria-label=\"Zoom\"\n          className=\"absolute bottom-3 right-3 flex items-center gap-0.5 rounded-lg border border-zinc-900/10 bg-white/90 p-1 shadow-[0_8px_24px_-12px_rgb(0_0_0/0.3)] backdrop-blur-sm dark:border-white/10 dark:bg-[#1a1d21]/90\"\n        >\n          <button type=\"button\" ref={zoomOutRef} aria-label=\"Zoom out\" className={button} onClick={actions.zoomOut}>\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n              <path d=\"M3.5 8h9\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </svg>\n          </button>\n          <button\n            type=\"button\"\n            aria-label=\"Zoom to 100%\"\n            title=\"Zoom to 100%\"\n            className={`${button} w-14 font-mono text-[11px] tabular-nums tracking-tight`}\n            onClick={() => actions.zoomTo(1)}\n          >\n            <span ref={readoutRef}>100%</span>\n          </button>\n          <button type=\"button\" ref={zoomInRef} aria-label=\"Zoom in\" className={button} onClick={actions.zoomIn}>\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n              <path d=\"M3.5 8h9M8 3.5v9\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </svg>\n          </button>\n          <span aria-hidden=\"true\" className=\"mx-0.5 h-4 w-px bg-zinc-900/10 dark:bg-white/10\" />\n          <button type=\"button\" aria-label=\"Fit to content\" title=\"Fit to content\" className={button} onClick={actions.fitToContent}>\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n              <path d=\"M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n          <button type=\"button\" aria-label=\"Reset view\" title=\"Reset view\" className={button} onClick={actions.reset}>\n            <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"size-3.5\">\n              <path d=\"M3 8a5 5 0 1 0 1.6-3.7M3 2.5v2.8h2.8\" stroke=\"currentColor\" strokeWidth=\"1.4\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n            </svg>\n          </button>\n        </div>\n      ) : null}\n    </div>\n  );\n});\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "cards"
  ],
  "docs": "Documentation: https://wancraft.my/components/pan-zoom-canvas"
}
