{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "zoom-pan", "title": "ZoomPan", "description": "A generic zoom and pan wrapper for any React content. Supports scroll-to-zoom, click-drag pan, and pinch-to-zoom.", "dependencies": [ "lucide-react" ], "files": [ { "path": "components/zoom-pan.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { Loader2 } from \"lucide-react\";\n\nexport interface ZoomPanProps {\n imageSrc?: string;\n children?: React.ReactNode;\n minScale?: number;\n maxScale?: number;\n initialScale?: number;\n zoomStep?: number;\n className?: string;\n onLoad?: () => void;\n controls?: (api: {\n zoomIn: () => void;\n zoomOut: () => void;\n resetZoom: () => void;\n centerView: () => void;\n scalePercent: number;\n }) => React.ReactNode;\n isLoading?: boolean;\n loadingFallback?: React.ReactNode;\n}\n\nexport function ZoomPan({\n imageSrc,\n children,\n minScale = 0.1,\n maxScale = 5,\n initialScale = 1,\n zoomStep = 0.1,\n className = \"\",\n onLoad,\n controls,\n isLoading = false,\n loadingFallback,\n}: ZoomPanProps) {\n const canvasRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const imageRef = React.useRef(null);\n\n // Transform refs\n const currentRef = React.useRef({ x: 0, y: 0, scale: initialScale });\n const targetRef = React.useRef({ x: 0, y: 0, scale: initialScale });\n\n // UI state for controls\n const [scalePercent, setScalePercent] = React.useState(\n Math.round(initialScale * 100),\n );\n\n // Interaction refs\n const isDragging = React.useRef(false);\n const isPinching = React.useRef(false);\n const panStartRef = React.useRef({ x: 0, y: 0 });\n const targetStartRef = React.useRef({ x: 0, y: 0 });\n\n // Animation/Raf ref\n const rafRef = React.useRef(null);\n const hasCentered = React.useRef(false);\n\n // Touch refs\n const touchStartRef = React.useRef<{\n touches: Array<{ x: number; y: number }>;\n translateX: number;\n translateY: number;\n scale: number;\n distance: number;\n center: { x: number; y: number };\n } | null>(null);\n\n const getTouchDistance = (touches: React.TouchList | TouchList) => {\n const dx = touches[0].clientX - touches[1].clientX;\n const dy = touches[0].clientY - touches[1].clientY;\n return Math.sqrt(dx * dx + dy * dy);\n };\n\n const getTouchCenter = (touches: React.TouchList | TouchList) => {\n return {\n x: (touches[0].clientX + touches[1].clientX) / 2,\n y: (touches[0].clientY + touches[1].clientY) / 2,\n };\n };\n\n // Render canvas\n const render = React.useCallback(() => {\n const canvas = canvasRef.current;\n const image = imageRef.current;\n if (!canvas || !image) return;\n\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n const { x, y, scale } = currentRef.current;\n const dpr =\n typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n\n // Clear canvas\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n ctx.save();\n ctx.scale(dpr, dpr);\n ctx.translate(x, y);\n ctx.scale(scale, scale);\n\n // Draw image\n ctx.drawImage(image, 0, 0, image.width, image.height);\n\n ctx.restore();\n }, []);\n\n // Mode 1: Snappy Update (Instant)\n // Used for drag, pinch, and wheel to ensure 1:1 input response\n const updateImmediate = React.useCallback(() => {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n\n rafRef.current = requestAnimationFrame(() => {\n // Direct 1:1 sync with target\n currentRef.current.x = targetRef.current.x;\n currentRef.current.y = targetRef.current.y;\n currentRef.current.scale = targetRef.current.scale;\n\n render();\n\n const newPercent = Math.round(currentRef.current.scale * 100);\n setScalePercent((prev) => {\n if (prev !== newPercent) return newPercent;\n return prev;\n });\n\n rafRef.current = null;\n });\n }, [render]);\n\n // Mode 2: Smooth Update (Interpolated)\n // Used for buttons (zoom in/out, reset, center) to provide a nice feel\n const updateSmooth = React.useCallback(() => {\n if (rafRef.current) cancelAnimationFrame(rafRef.current);\n\n const loop = () => {\n const target = targetRef.current;\n const current = currentRef.current;\n\n const lerp = 0.3; // Smoothing factor (higher = faster/snappier)\n const dist_x = target.x - current.x;\n const dist_y = target.y - current.y;\n const dist_s = target.scale - current.scale;\n\n // Stop condition: close enough to target\n if (\n Math.abs(dist_x) < 0.5 &&\n Math.abs(dist_y) < 0.5 &&\n Math.abs(dist_s) < 0.001\n ) {\n current.x = target.x;\n current.y = target.y;\n current.scale = target.scale;\n render();\n setScalePercent(Math.round(current.scale * 100));\n rafRef.current = null;\n return;\n }\n\n // Interpolate\n current.x += dist_x * lerp;\n current.y += dist_y * lerp;\n current.scale += dist_s * lerp;\n\n render();\n setScalePercent(Math.round(current.scale * 100));\n rafRef.current = requestAnimationFrame(loop);\n };\n\n rafRef.current = requestAnimationFrame(loop);\n }, [render]);\n\n // Clean up RAF on unmount\n React.useEffect(() => {\n return () => {\n if (rafRef.current) {\n cancelAnimationFrame(rafRef.current);\n }\n };\n }, []);\n\n // Logic functions for API\n const applyZoom = React.useCallback(\n (delta: number) => {\n const target = targetRef.current;\n if (!containerRef.current) return;\n const rect = containerRef.current.getBoundingClientRect();\n const centerX = rect.width / 2;\n const centerY = rect.height / 2;\n\n const newScale = Math.min(\n maxScale,\n Math.max(minScale, target.scale + delta),\n );\n const ratio = newScale / target.scale;\n\n target.x = centerX - (centerX - target.x) * ratio;\n target.y = centerY - (centerY - target.y) * ratio;\n target.scale = newScale;\n\n updateSmooth();\n },\n [maxScale, minScale, updateSmooth],\n );\n\n const zoomIn = React.useCallback(\n () => applyZoom(zoomStep),\n [applyZoom, zoomStep],\n );\n const zoomOut = React.useCallback(\n () => applyZoom(-zoomStep),\n [applyZoom, zoomStep],\n );\n\n const resetZoom = React.useCallback(() => {\n targetRef.current = { x: 0, y: 0, scale: initialScale };\n updateSmooth();\n }, [initialScale, updateSmooth]);\n\n // Shared calculation for centering without applying it yet\n const getCenterTransform = React.useCallback(() => {\n const canvas = canvasRef.current;\n const image = imageRef.current;\n if (!canvas || !image) return null;\n\n const scaleX = canvas.clientWidth / image.width;\n const scaleY = canvas.clientHeight / image.height;\n const scale = Math.min(scaleX, scaleY) * 0.9;\n\n const x = (canvas.clientWidth - image.width * scale) / 2;\n const y = (canvas.clientHeight - image.height * scale) / 2;\n\n return { x, y, scale };\n }, []);\n\n const centerView = React.useCallback(() => {\n const center = getCenterTransform();\n if (!center) return;\n targetRef.current = center;\n updateSmooth();\n }, [getCenterTransform, updateSmooth]);\n\n const [api, setApi] = React.useState<{\n zoomIn: () => void;\n zoomOut: () => void;\n resetZoom: () => void;\n centerView: () => void;\n scalePercent: number;\n } | null>(null);\n\n React.useEffect(() => {\n setApi({\n zoomIn,\n zoomOut,\n resetZoom,\n centerView,\n scalePercent,\n });\n }, [zoomIn, zoomOut, resetZoom, centerView, scalePercent]);\n\n // Mouse handlers\n const handleMouseDown = (e: React.MouseEvent) => {\n if (e.button !== 0) return;\n e.preventDefault();\n isDragging.current = true;\n\n // Sync logic: grab exactly where we are, cancelling any smooth animation\n targetRef.current = { ...currentRef.current };\n panStartRef.current = { x: e.clientX, y: e.clientY };\n targetStartRef.current = { ...targetRef.current };\n\n updateImmediate();\n };\n\n // Setup non-passive wheel event listener\n React.useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const onWheel = (e: WheelEvent) => {\n e.preventDefault();\n\n const rect = canvas.getBoundingClientRect();\n const mouseX = e.clientX - rect.left;\n const mouseY = e.clientY - rect.top;\n\n let delta = e.deltaY;\n if (e.deltaMode === 1) delta *= 40;\n const ZOOM_SENSITIVITY = 0.0015;\n const scaleFactor = Math.exp(-delta * ZOOM_SENSITIVITY);\n\n const current = currentRef.current;\n const target = targetRef.current;\n\n const effectiveScale = Math.min(\n maxScale,\n Math.max(minScale, current.scale * scaleFactor),\n );\n const ratio = effectiveScale / current.scale;\n\n target.x = mouseX - (mouseX - current.x) * ratio;\n target.y = mouseY - (mouseY - current.y) * ratio;\n target.scale = effectiveScale;\n\n updateImmediate();\n };\n\n canvas.addEventListener(\"wheel\", onWheel, { passive: false });\n\n return () => {\n canvas.removeEventListener(\"wheel\", onWheel);\n };\n }, [maxScale, minScale, updateImmediate]);\n\n // Window events for dragging\n React.useEffect(() => {\n const handleMouseMove = (e: MouseEvent) => {\n if (!isDragging.current) return;\n const dx = e.clientX - panStartRef.current.x;\n const dy = e.clientY - panStartRef.current.y;\n\n targetRef.current.x = targetStartRef.current.x + dx;\n targetRef.current.y = targetStartRef.current.y + dy;\n\n updateImmediate();\n };\n\n const handleMouseUp = () => {\n isDragging.current = false;\n };\n\n window.addEventListener(\"mousemove\", handleMouseMove);\n window.addEventListener(\"mouseup\", handleMouseUp);\n return () => {\n window.removeEventListener(\"mousemove\", handleMouseMove);\n window.removeEventListener(\"mouseup\", handleMouseUp);\n };\n }, [updateImmediate]);\n\n // Touch handlers\n const handleTouchStart = (e: React.TouchEvent) => {\n // Sync current state to target to stop any smooth animation instantly\n targetRef.current = { ...currentRef.current };\n\n if (e.touches.length === 1) {\n isDragging.current = true;\n touchStartRef.current = {\n touches: [{ x: e.touches[0].clientX, y: e.touches[0].clientY }],\n translateX: currentRef.current.x,\n translateY: currentRef.current.y,\n scale: currentRef.current.scale,\n distance: 0,\n center: { x: 0, y: 0 },\n };\n } else if (e.touches.length === 2) {\n isPinching.current = true;\n isDragging.current = false;\n\n touchStartRef.current = {\n touches: [\n { x: e.touches[0].clientX, y: e.touches[0].clientY },\n { x: e.touches[1].clientX, y: e.touches[1].clientY },\n ],\n translateX: currentRef.current.x,\n translateY: currentRef.current.y,\n scale: currentRef.current.scale,\n distance: getTouchDistance(e.touches),\n center: getTouchCenter(e.touches),\n };\n }\n updateImmediate();\n };\n\n const handleTouchMove = (e: React.TouchEvent) => {\n if (e.cancelable) e.preventDefault();\n if (!touchStartRef.current) return;\n\n if (e.touches.length === 1 && isDragging.current) {\n const dx = e.touches[0].clientX - touchStartRef.current.touches[0].x;\n const dy = e.touches[0].clientY - touchStartRef.current.touches[0].y;\n\n targetRef.current.x = touchStartRef.current.translateX + dx;\n targetRef.current.y = touchStartRef.current.translateY + dy;\n } else if (e.touches.length === 2) {\n const newDist = getTouchDistance(e.touches);\n const newCenter = getTouchCenter(e.touches);\n const rect = canvasRef.current?.getBoundingClientRect() || {\n left: 0,\n top: 0,\n };\n\n const scaleRatio = newDist / touchStartRef.current.distance;\n const newScale = Math.min(\n maxScale,\n Math.max(minScale, touchStartRef.current.scale * scaleRatio),\n );\n\n const oldScale = touchStartRef.current.scale;\n const oldX = touchStartRef.current.translateX;\n\n const oldCenterRelX = touchStartRef.current.center.x - rect.left;\n const oldCenterRelY = touchStartRef.current.center.y - rect.top;\n\n const contentX = (oldCenterRelX - oldX) / oldScale;\n const contentY =\n (oldCenterRelY - touchStartRef.current.translateY) / oldScale;\n\n const newCenterRelX = newCenter.x - rect.left;\n const newCenterRelY = newCenter.y - rect.top;\n\n targetRef.current.scale = newScale;\n targetRef.current.x = newCenterRelX - contentX * newScale;\n targetRef.current.y = newCenterRelY - contentY * newScale;\n }\n\n updateImmediate();\n };\n\n // ResizeObserver\n React.useEffect(() => {\n const container = containerRef.current;\n if (!container) return;\n\n const observer = new ResizeObserver((entries) => {\n const entry = entries[0];\n if (!entry) return;\n\n const { width, height } = entry.contentRect;\n const canvas = canvasRef.current;\n if (!canvas) return;\n\n const dpr =\n typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1;\n canvas.width = width * dpr;\n canvas.height = height * dpr;\n canvas.style.width = `${width}px`;\n canvas.style.height = `${height}px`;\n\n // Perform initial centering if we haven't yet and we have a valid size\n // and an image is already loaded.\n if (!hasCentered.current && imageRef.current && width > 0 && height > 0) {\n const center = getCenterTransform();\n if (center) {\n targetRef.current = center;\n currentRef.current = center;\n hasCentered.current = true;\n }\n }\n\n render();\n });\n\n observer.observe(container);\n return () => observer.disconnect();\n }, [render, getCenterTransform]);\n\n // Image loading logic\n React.useEffect(() => {\n if (!imageSrc) {\n imageRef.current = null;\n hasCentered.current = false;\n render();\n return;\n }\n\n const image = new Image();\n image.crossOrigin = \"anonymous\";\n image.onload = () => {\n imageRef.current = image;\n\n // Calculate center for initial display\n // We manually implement \"snap to center\" here to avoid animation on load\n const canvas = canvasRef.current;\n if (canvas && canvas.clientWidth > 0 && canvas.clientHeight > 0) {\n const scaleX = canvas.clientWidth / image.width;\n const scaleY = canvas.clientHeight / image.height;\n const scale = Math.min(scaleX, scaleY) * 0.9;\n\n const x = (canvas.clientWidth - image.width * scale) / 2;\n const y = (canvas.clientHeight - image.height * scale) / 2;\n\n const center = { x, y, scale };\n targetRef.current = center;\n currentRef.current = center;\n hasCentered.current = true;\n }\n\n render();\n onLoad?.();\n };\n image.src = imageSrc;\n }, [imageSrc, onLoad, render, getCenterTransform]);\n\n return (\n \n {controls && api && controls(api)}\n\n
\n {\n isDragging.current = false;\n isPinching.current = false;\n }}\n className=\"block w-full h-full touch-none\"\n />\n\n
\n {children}\n
\n\n {isLoading && (\n
\n {loadingFallback || (\n \n )}\n
\n )}\n
\n \n );\n}\n", "type": "registry:component", "target": "components/mermaidcn/zoom-pan.tsx" } ], "type": "registry:component" }