/ src / components / org-chart / use-org-chart-pan-zoom.ts
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  }