use-org-chart-pan-zoom.ts
1 'use client' 2 3 import { useCallback, useRef, useState } from 'react' 4 5 export interface Transform { 6 x: number 7 y: number 8 scale: number 9 } 10 11 const MIN_SCALE = 0.15 12 const MAX_SCALE = 3 13 const ZOOM_FACTOR = 0.002 14 15 export function useOrgChartPanZoom() { 16 const [transform, setTransform] = useState<Transform>({ x: 0, y: 0, scale: 1 }) 17 const isPanning = useRef(false) 18 const lastPointer = useRef({ x: 0, y: 0 }) 19 20 const onWheel = useCallback((e: React.WheelEvent) => { 21 const container = e.currentTarget as HTMLElement 22 if (!container.contains(e.target as Node)) return 23 e.preventDefault() 24 // Capture rect before the async setState callback 25 const rect = container.getBoundingClientRect() 26 const clientX = e.clientX 27 const clientY = e.clientY 28 const deltaY = e.deltaY 29 setTransform((prev) => { 30 const delta = -deltaY * ZOOM_FACTOR 31 const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, prev.scale * (1 + delta))) 32 const ratio = nextScale / prev.scale 33 const px = clientX - rect.left 34 const py = clientY - rect.top 35 return { 36 x: px - (px - prev.x) * ratio, 37 y: py - (py - prev.y) * ratio, 38 scale: nextScale, 39 } 40 }) 41 }, []) 42 43 const onPointerDown = useCallback((e: React.PointerEvent) => { 44 // Left-click (no modifier), middle-click, or alt+left-click all pan. 45 // Left-click on nodes won't reach here because nodes call e.stopPropagation(). 46 // Guard: only capture when the DOM target is inside the container (React portals 47 // bubble events through the React tree even though the DOM target is elsewhere). 48 if (e.button === 0 || e.button === 1) { 49 const container = e.currentTarget as HTMLElement 50 if (!container.contains(e.target as Node)) return 51 e.preventDefault() 52 isPanning.current = true 53 lastPointer.current = { x: e.clientX, y: e.clientY } 54 container.setPointerCapture(e.pointerId) 55 } 56 }, []) 57 58 const onPointerMove = useCallback((e: React.PointerEvent) => { 59 if (!isPanning.current) return 60 const dx = e.clientX - lastPointer.current.x 61 const dy = e.clientY - lastPointer.current.y 62 lastPointer.current = { x: e.clientX, y: e.clientY } 63 setTransform((prev) => ({ ...prev, x: prev.x + dx, y: prev.y + dy })) 64 }, []) 65 66 const onPointerUp = useCallback((e: React.PointerEvent) => { 67 if (isPanning.current) { 68 isPanning.current = false 69 try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) } catch (_err: unknown) { /* already released */ } 70 } 71 }, []) 72 73 const zoomIn = useCallback(() => { 74 setTransform((prev) => ({ ...prev, scale: Math.min(MAX_SCALE, prev.scale * 1.25) })) 75 }, []) 76 77 const zoomOut = useCallback(() => { 78 setTransform((prev) => ({ ...prev, scale: Math.max(MIN_SCALE, prev.scale / 1.25) })) 79 }, []) 80 81 const fitToScreen = useCallback((bounds: { minX: number; minY: number; maxX: number; maxY: number }, viewport: { width: number; height: number }) => { 82 const bw = bounds.maxX - bounds.minX + 200 83 const bh = bounds.maxY - bounds.minY + 200 84 if (bw <= 0 || bh <= 0) return 85 const scale = Math.min(viewport.width / bw, viewport.height / bh, 1.5) 86 const cx = (bounds.minX + bounds.maxX) / 2 87 const cy = (bounds.minY + bounds.maxY) / 2 88 setTransform({ 89 x: viewport.width / 2 - cx * scale, 90 y: viewport.height / 2 - cy * scale, 91 scale, 92 }) 93 }, []) 94 95 const resetView = useCallback(() => { 96 setTransform({ x: 0, y: 0, scale: 1 }) 97 }, []) 98 99 return { 100 transform, 101 setTransform, 102 handlers: { onWheel, onPointerDown, onPointerMove, onPointerUp }, 103 zoomIn, 104 zoomOut, 105 fitToScreen, 106 resetView, 107 } 108 }