Canvas Pan/Zoom in React

Canvas Pan/Zoom in React

·

5 min read

In this article, I will show you how to implement canvas zoom and pan functions in React.

Step 1: Canvas Component

Build a Canvas component first, includes a html canvas element and a hidden image which will be draw/zoom/pan in the canvas.

function Canvas() {
    const canvasRef = useRef();
    const imgRef = useRef();

    const handleImgError = (e) => {
        console.error(e);
    };

    return (
        <div>
            <canvas
                ref={canvasRef}
            ></canvas>
            <img
                ref={imgRef}
                src={img}
                style={{ display: "none" }}
                onError={handleImgError}
            />
        </div>
    );
}

Step 2: Canvas Setup

Next things is to set up canvas size to be equal to the window size. I use a useEffect, in which listening to the window resize event to ajust canvas accordingly.

useEffect(() => {
    const handleResize = () => {
        const canvas = canvasRef.current;
        if (!canvas) return;
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
    };
    window.addEventListener("resize", handleResize);
    handleResize();

    return () => {
        window.removeEventListener("resize", handleResize);
    };
}, []);

Step 3: Draw Loop Setup

Now is the time to set up a draw loop. I use the requestAnimationFrame function a call the draw function constantly. Note that I use a ref again to save the draw function which will be implemented in the following step.

const drawFuncRef = useRef();

useEffect(() => {
    let handle;
    const draw = () => {
        if (drawFuncRef.current) {
            drawFuncRef.current();
        }
        handle = window.requestAnimationFrame(draw);
    };
    draw();

    return () => {
        window.cancelAnimationFrame(handle);
    };
}, []);

Step 4: States

In order to make the image can be zoom/pann, some states needed to be defined:

  • pos: image position in the canvas
  • isMouseDown: if mouse down
  • imgStartPos: image position before image be panned
  • mouseStartPos: mouse position before image be panned
  • zoomRatio: zoom in/out parameter
// img position
const [pos, setPos] = useState({ x: 0, y: 0 });

const [isMouseDown, setIsMouseDown] = useState(false);

// img move start position
const [imgStartPos, setImgStartPos] = useState({ x: 0, y: 0 });
// mouse move start position
const [mouseStartPos, setMouseStartPos] = useState({ x: 0, y: 0 });

// zoom ratio
const [zoomRatio, setZoomRatio] = useState(1);

And then update these positions in mouse events.

const handleMouseDown = e => {
    setImgStartPos({ ...pos });
    setMouseStartPos({ x: e.clientX, y: e.clientY });
    setIsMouseDown(true);
};

const handleMouseUp = e => {
    setIsMouseDown(false);
};

const handleMouseMove = e => {
    if (isMouseDown) {
        const offsetX = e.clientX - mouseStartPos.x;
        const offsetY = e.clientY - mouseStartPos.y;
        setPos({ x: imgStartPos.x + offsetX, y: imgStartPos.y + offsetY });
    }
};

And then update zoom ratio in wheel event.

useEffect(() => {
    const delta = 0.05;
    const max = 5;
    const min = 0.1;

    const handleScroll = e => {
        if (e.deltaY > 0) {
            setZoomRatio(v => v + delta <= max ? v + delta : max);
        } else {
            setZoomRatio(v => v - delta >= min ? v - delta : min);
        }
    };
    window.addEventListener("wheel", handleScroll);

    return () => {
        window.removeEventListener("wheel", handleScroll);
    };
}, []);

Step 5: Draw Function

Now is the time to define the actual draw function.

useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const img = imgRef.current;
    if (!img) return;

    const ctx = canvas.getContext("2d");

    drawFuncRef.current = () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        ctx.drawImage(
            img,
            0, 0, img.width, img.height,
            pos.x, pos.y, img.width * zoomRatio, img.height * zoomRatio
        );
    };

}, [pos, zoomRatio]);

You may ask why did I save this actual draw function in a ref? This is because, in the draw function, some states(pos/zoomRatio) is used. Whenever this states being changed, the function need to be recreated to use the updated state values. As you can see that the pos/zoomRatio state are in the useEffect dependencies.

The other approach to solve this problem is to add theses states in the draw loop useEffect and define the draw function in there. That is ok too, but in that way, everytime these states changes, you not only need to update draw function, but also need to stop/restart the draw loop. So for clarity, I save this draw function in a ref, so I can update it without considering other things.

Full Code

That's all the key steps to make the image can be zoom/pan. Note that this is just a simple demo, a lot of edge cases are not solved, don't use it in production directly.

import { useEffect, useRef, useState } from 'react';
import './App.css';
import img from "./img.png";

function Canvas() {
    const canvasRef = useRef();
    const imgRef = useRef();
    const drawFuncRef = useRef();

    // img position
    const [pos, setPos] = useState({ x: 0, y: 0 });

    const [isMouseDown, setIsMouseDown] = useState(false);

    // img move start position
    const [imgStartPos, setImgStartPos] = useState({ x: 0, y: 0 });
    // mouse move start position
    const [mouseStartPos, setMouseStartPos] = useState({ x: 0, y: 0 });

    // zoom ratio
    const [zoomRatio, setZoomRatio] = useState(1);

    // set up canvas size
    useEffect(() => {
        const handleResize = () => {
            const canvas = canvasRef.current;
            if (!canvas) return;
            canvas.width = window.innerWidth;
            canvas.height = window.innerHeight;
        };
        window.addEventListener("resize", handleResize);
        handleResize();

        return () => {
            window.removeEventListener("resize", handleResize);
        };
    }, []);

    // set up draw loop
    useEffect(() => {
        let handle;
        const draw = () => {
            if (drawFuncRef.current) {
                drawFuncRef.current();
            }
            handle = window.requestAnimationFrame(draw);
        };
        draw();

        return () => {
            window.cancelAnimationFrame(handle);
        };
    }, []);

    // set up draw image function
    useEffect(() => {
        const canvas = canvasRef.current;
        if (!canvas) return;

        const img = imgRef.current;
        if (!img) return;

        const ctx = canvas.getContext("2d");

        drawFuncRef.current = () => {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            ctx.drawImage(
                img,
                0, 0, img.width, img.height,
                pos.x, pos.y, img.width * zoomRatio, img.height * zoomRatio
            );
        };

    }, [pos, zoomRatio]);


    // set up move with mouse
    const handleMouseDown = e => {
        setImgStartPos({ ...pos });
        setMouseStartPos({ x: e.clientX, y: e.clientY });
        setIsMouseDown(true);
    };
    const handleMouseUp = e => {
        setIsMouseDown(false);
    };
    const handleMouseMove = e => {
        if (isMouseDown) {
            const offsetX = e.clientX - mouseStartPos.x;
            const offsetY = e.clientY - mouseStartPos.y;
            setPos({ x: imgStartPos.x + offsetX, y: imgStartPos.y + offsetY });
        }
    };

    // set up zoom in/out
    useEffect(() => {
        const delta = 0.05;
        const max = 5;
        const min = 0.1;

        const handleScroll = e => {
            if (e.deltaY > 0) {
                setZoomRatio(v => v + delta <= max ? v + delta : max);
            } else {
                setZoomRatio(v => v - delta >= min ? v - delta : min);
            }
        };
        window.addEventListener("wheel", handleScroll);

        return () => {
            window.removeEventListener("wheel", handleScroll);
        };
    }, []);


    const handleImgError = (e) => {
        console.error(e);
    };

    return (
        <div>
            <canvas
                ref={canvasRef}
                onMouseDown={handleMouseDown}
                onMouseUp={handleMouseUp}
                onMouseMove={handleMouseMove}
            ></canvas>
            <img
                ref={imgRef}
                src={img}
                style={{ display: "none" }}
                onError={handleImgError}
            />
        </div>
    );
}

function App() {
    return (
        <div className="App">
            <Canvas />
        </div>
    );
}

export default App;