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;