All files / webview-app/src/hooks useCanvasZoomPan.ts

29.54% Statements 13/44
0% Branches 0/14
7.14% Functions 1/14
33.33% Lines 13/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112                                              2x             8x 8x 8x 8x   8x                                       8x                     8x                       8x               8x       8x       8x         8x                        
import type { PointerEvent, WheelEvent } from "react";
import { useCallback, useRef, useState } from "react";
 
import { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN } from "@/lib/constants";
import { clamp } from "@/lib/geometry";
 
interface Point {
  x: number;
  y: number;
}
 
export interface UseCanvasZoomPanResult {
  zoom: number;
  pan: Point;
  handleWheel: (event: WheelEvent<HTMLDivElement>) => void;
  handlePointerDown: (event: PointerEvent<HTMLDivElement>) => void;
  handlePointerMove: (event: PointerEvent<HTMLDivElement>) => void;
  handlePointerUp: (event: PointerEvent<HTMLDivElement>) => void;
  handleZoomIn: () => void;
  handleZoomOut: () => void;
  handleResetView: () => void;
}
 
const ZOOM_STEP = 0.1;
 
function roundZoom(value: number): number {
  return Number(value.toFixed(2));
}
 
export function useCanvasZoomPan(): UseCanvasZoomPanResult {
  const [zoom, setZoom] = useState<number>(ZOOM_DEFAULT);
  const [pan, setPan] = useState<Point>({ x: 0, y: 0 });
  const isPanningRef = useRef(false);
  const lastPointRef = useRef<Point | null>(null);
 
  const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
    event.preventDefault();
 
    if (event.ctrlKey) {
      setZoom((previousZoom) =>
        clamp(
          roundZoom(previousZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)),
          ZOOM_MIN,
          ZOOM_MAX,
        ),
      );
      return;
    }
 
    setPan((previousPan) => ({
      x: previousPan.x - event.deltaX,
      y: previousPan.y - event.deltaY,
    }));
  }, []);
 
  const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if (event.button !== 1 && !event.altKey) {
      return;
    }
 
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    isPanningRef.current = true;
    lastPointRef.current = { x: event.clientX, y: event.clientY };
  }, []);
 
  const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if (!isPanningRef.current || !lastPointRef.current) {
      return;
    }
 
    const deltaX = event.clientX - lastPointRef.current.x;
    const deltaY = event.clientY - lastPointRef.current.y;
 
    lastPointRef.current = { x: event.clientX, y: event.clientY };
    setPan((previousPan) => ({ x: previousPan.x + deltaX, y: previousPan.y + deltaY }));
  }, []);
 
  const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
    if (event.currentTarget.hasPointerCapture(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    isPanningRef.current = false;
    lastPointRef.current = null;
  }, []);
 
  const handleZoomOut = useCallback(() => {
    setZoom((previousZoom) => clamp(roundZoom(previousZoom - ZOOM_STEP), ZOOM_MIN, ZOOM_MAX));
  }, []);
 
  const handleZoomIn = useCallback(() => {
    setZoom((previousZoom) => clamp(roundZoom(previousZoom + ZOOM_STEP), ZOOM_MIN, ZOOM_MAX));
  }, []);
 
  const handleResetView = useCallback(() => {
    setZoom(ZOOM_DEFAULT);
    setPan({ x: 0, y: 0 });
  }, []);
 
  return {
    zoom,
    pan,
    handleWheel,
    handlePointerDown,
    handlePointerMove,
    handlePointerUp,
    handleZoomIn,
    handleZoomOut,
    handleResetView,
  };
}