import { useCallback, useState } from "react"; export const MIN_ZOOM = 0.5; export const MAX_ZOOM = 5; export const ZOOM_STEP = 0.25; interface State { zoom: number; panX: number; panY: number; } export interface ZoomPanResult { zoom: number; panX: number; panY: number; canZoomIn: boolean; canZoomOut: boolean; isZoomed: boolean; zoomIn: () => void; zoomOut: () => void; resetZoom: () => void; /** Translate by (dx, dy) screen pixels. */ pan: (dx: number, dy: number) => void; /** * Zoom by a multiplicative scale factor, anchored at a focal point. * focalX/focalY are in px relative to the container centre. */ zoomByScaleAtPoint: (scaleFactor: number, focalX: number, focalY: number) => void; /** * Zoom by an additive step delta, anchored at a focal point. * focalX/focalY are in px relative to the container centre. */ zoomByDeltaAtPoint: (delta: number, focalX: number, focalY: number) => void; /** * Simultaneously pan by (dx, dy) and zoom by a multiplicative scale factor, * anchored at a focal point. Used for 2-finger gestures where the midpoint * may translate while the fingers pinch. */ panAndZoomAtPoint: (dx: number, dy: number, scaleFactor: number, focalX: number, focalY: number) => void; } export function useZoomPan(): ZoomPanResult { let [{ zoom, panX, panY }, setState] = useState({ zoom: 1, panX: 0, panY: 0 }); let zoomIn = useCallback(() => { // Zoom from centre (focalX = 0, focalY = 0) setState((s) => { let newZoom = +Math.min(MAX_ZOOM, s.zoom + ZOOM_STEP).toFixed(2); let scale = newZoom / s.zoom; return { zoom: newZoom, panX: s.panX * scale, panY: s.panY * scale }; }); }, []); let zoomOut = useCallback(() => { setState((s) => { let newZoom = +Math.max(MIN_ZOOM, s.zoom - ZOOM_STEP).toFixed(2); let scale = newZoom / s.zoom; return { zoom: newZoom, panX: s.panX * scale, panY: s.panY * scale }; }); }, []); let resetZoom = useCallback(() => { setState({ zoom: 1, panX: 0, panY: 0 }); }, []); let pan = useCallback((dx: number, dy: number) => { setState((s) => ({ ...s, panX: s.panX + dx, panY: s.panY + dy })); }, []); let zoomByScaleAtPoint = useCallback((scaleFactor: number, focalX: number, focalY: number) => { setState((s) => { let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom * scaleFactor)).toFixed(3); let realScale = newZoom / s.zoom; return { zoom: newZoom, // Keep the focal point fixed on screen: pan adjusts to compensate for the zoom shift. panX: focalX * (1 - realScale) + s.panX * realScale, panY: focalY * (1 - realScale) + s.panY * realScale, }; }); }, []); let zoomByDeltaAtPoint = useCallback((delta: number, focalX: number, focalY: number) => { setState((s) => { let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom + delta)).toFixed(2); let scale = newZoom / s.zoom; return { zoom: newZoom, panX: focalX * (1 - scale) + s.panX * scale, panY: focalY * (1 - scale) + s.panY * scale, }; }); }, []); let panAndZoomAtPoint = useCallback((dx: number, dy: number, scaleFactor: number, focalX: number, focalY: number) => { setState((s) => { let newZoom = +Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, s.zoom * scaleFactor)).toFixed(3); let realScale = newZoom / s.zoom; // Apply pan first, then zoom around the focal point. let panXAfterPan = s.panX + dx; let panYAfterPan = s.panY + dy; return { zoom: newZoom, panX: focalX * (1 - realScale) + panXAfterPan * realScale, panY: focalY * (1 - realScale) + panYAfterPan * realScale, }; }); }, []); return { zoom, panX, panY, canZoomIn: zoom < MAX_ZOOM, canZoomOut: zoom > MIN_ZOOM, isZoomed: zoom !== 1 || panX !== 0 || panY !== 0, zoomIn, zoomOut, resetZoom, pan, zoomByScaleAtPoint, zoomByDeltaAtPoint, panAndZoomAtPoint, }; }