Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A pannable, zoomable, html5 canvas in React and Typescript
// sandbox here: https://codesandbox.io/s/p3itj?file=/src/Canvas.tsx
import {
useEffect,
useCallback,
useLayoutEffect,
useRef,
useState
} from "react";
import * as React from "react";
type CanvasProps = {
canvasWidth: number;
canvasHeight: number;
};
type Point = {
x: number;
y: number;
};
const ORIGIN = Object.freeze({ x: 0, y: 0 });
// adjust to device to avoid blur
const { devicePixelRatio: ratio = 1 } = window;
function diffPoints(p1: Point, p2: Point) {
return { x: p1.x - p2.x, y: p1.y - p2.y };
}
function addPoints(p1: Point, p2: Point) {
return { x: p1.x + p2.x, y: p1.y + p2.y };
}
function scalePoint(p1: Point, scale: number) {
return { x: p1.x / scale, y: p1.y / scale };
}
const ZOOM_SENSITIVITY = 500; // bigger for lower zoom per scroll
export default function Canvas(props: CanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [context, setContext] = useState<CanvasRenderingContext2D | null>(null);
const [scale, setScale] = useState<number>(1);
const [offset, setOffset] = useState<Point>(ORIGIN);
const [mousePos, setMousePos] = useState<Point>(ORIGIN);
const [viewportTopLeft, setViewportTopLeft] = useState<Point>(ORIGIN);
const isResetRef = useRef<boolean>(false);
const lastMousePosRef = useRef<Point>(ORIGIN);
const lastOffsetRef = useRef<Point>(ORIGIN);
// update last offset
useEffect(() => {
lastOffsetRef.current = offset;
}, [offset]);
// reset
const reset = useCallback(
(context: CanvasRenderingContext2D) => {
if (context && !isResetRef.current) {
// adjust for device pixel density
context.canvas.width = props.canvasWidth * ratio;
context.canvas.height = props.canvasHeight * ratio;
context.scale(ratio, ratio);
setScale(1);
// reset state and refs
setContext(context);
setOffset(ORIGIN);
setMousePos(ORIGIN);
setViewportTopLeft(ORIGIN);
lastOffsetRef.current = ORIGIN;
lastMousePosRef.current = ORIGIN;
// this thing is so multiple resets in a row don't clear canvas
isResetRef.current = true;
}
},
[props.canvasWidth, props.canvasHeight]
);
// functions for panning
const mouseMove = useCallback(
(event: MouseEvent) => {
if (context) {
const lastMousePos = lastMousePosRef.current;
const currentMousePos = { x: event.pageX, y: event.pageY }; // use document so can pan off element
lastMousePosRef.current = currentMousePos;
const mouseDiff = diffPoints(currentMousePos, lastMousePos);
setOffset((prevOffset) => addPoints(prevOffset, mouseDiff));
}
},
[context]
);
const mouseUp = useCallback(() => {
document.removeEventListener("mousemove", mouseMove);
document.removeEventListener("mouseup", mouseUp);
}, [mouseMove]);
const startPan = useCallback(
(event: React.MouseEvent<HTMLCanvasElement, MouseEvent>) => {
document.addEventListener("mousemove", mouseMove);
document.addEventListener("mouseup", mouseUp);
lastMousePosRef.current = { x: event.pageX, y: event.pageY };
},
[mouseMove, mouseUp]
);
// setup canvas and set context
useLayoutEffect(() => {
if (canvasRef.current) {
// get new drawing context
const renderCtx = canvasRef.current.getContext("2d");
if (renderCtx) {
reset(renderCtx);
}
}
}, [reset, props.canvasHeight, props.canvasWidth]);
// pan when offset or scale changes
useLayoutEffect(() => {
if (context && lastOffsetRef.current) {
const offsetDiff = scalePoint(
diffPoints(offset, lastOffsetRef.current),
scale
);
context.translate(offsetDiff.x, offsetDiff.y);
setViewportTopLeft((prevVal) => diffPoints(prevVal, offsetDiff));
isResetRef.current = false;
}
}, [context, offset, scale]);
// draw
useLayoutEffect(() => {
if (context) {
const squareSize = 20;
// clear canvas but maintain transform
const storedTransform = context.getTransform();
context.canvas.width = context.canvas.width;
context.setTransform(storedTransform);
context.fillRect(
props.canvasWidth / 2 - squareSize / 2,
props.canvasHeight / 2 - squareSize / 2,
squareSize,
squareSize
);
context.arc(viewportTopLeft.x, viewportTopLeft.y, 5, 0, 2 * Math.PI);
context.fillStyle = "red";
context.fill();
}
}, [
props.canvasWidth,
props.canvasHeight,
context,
scale,
offset,
viewportTopLeft
]);
// add event listener on canvas for mouse position
useEffect(() => {
const canvasElem = canvasRef.current;
if (canvasElem === null) {
return;
}
function handleUpdateMouse(event: MouseEvent) {
event.preventDefault();
if (canvasRef.current) {
const viewportMousePos = { x: event.clientX, y: event.clientY };
const topLeftCanvasPos = {
x: canvasRef.current.offsetLeft,
y: canvasRef.current.offsetTop
};
setMousePos(diffPoints(viewportMousePos, topLeftCanvasPos));
}
}
canvasElem.addEventListener("mousemove", handleUpdateMouse);
canvasElem.addEventListener("wheel", handleUpdateMouse);
return () => {
canvasElem.removeEventListener("mousemove", handleUpdateMouse);
canvasElem.removeEventListener("wheel", handleUpdateMouse);
};
}, []);
// add event listener on canvas for zoom
useEffect(() => {
const canvasElem = canvasRef.current;
if (canvasElem === null) {
return;
}
// this is tricky. Update the viewport's "origin" such that
// the mouse doesn't move during scale - the 'zoom point' of the mouse
// before and after zoom is relatively the same position on the viewport
function handleWheel(event: WheelEvent) {
event.preventDefault();
if (context) {
const zoom = 1 - event.deltaY / ZOOM_SENSITIVITY;
const viewportTopLeftDelta = {
x: (mousePos.x / scale) * (1 - 1 / zoom),
y: (mousePos.y / scale) * (1 - 1 / zoom)
};
const newViewportTopLeft = addPoints(
viewportTopLeft,
viewportTopLeftDelta
);
context.translate(viewportTopLeft.x, viewportTopLeft.y);
context.scale(zoom, zoom);
context.translate(-newViewportTopLeft.x, -newViewportTopLeft.y);
setViewportTopLeft(newViewportTopLeft);
setScale(scale * zoom);
isResetRef.current = false;
}
}
canvasElem.addEventListener("wheel", handleWheel);
return () => canvasElem.removeEventListener("wheel", handleWheel);
}, [context, mousePos.x, mousePos.y, viewportTopLeft, scale]);
return (
<div>
<button onClick={() => context && reset(context)}>Reset</button>
<pre>scale: {scale}</pre>
<pre>offset: {JSON.stringify(offset)}</pre>
<pre>viewportTopLeft: {JSON.stringify(viewportTopLeft)}</pre>
<canvas
onMouseDown={startPan}
ref={canvasRef}
width={props.canvasWidth * ratio}
height={props.canvasHeight * ratio}
style={{
border: "2px solid #000",
width: `${props.canvasWidth}px`,
height: `${props.canvasHeight}px`
}}
></canvas>
</div>
);
}
@emielvangoor
Copy link

emielvangoor commented Jan 9, 2022

Is there a way to have text in the upper left corner using viewportTopLeft but keep a scale of 1, i don't want it to zoom together with the rest.

update:
I've implemented this by scaling the font size with the zoom factor.

@robinovitch61
Copy link
Author

robinovitch61 commented Jan 10, 2022

@emielvangoor glad you got it figured out!

If others find it useful, I have a un-React-ified version of this here with slightly cleaner logic than what I show in the gist above: https://github.com/robinovitch61/hotstuff/tree/main/canvas_testing

And a React hook for this logic here: https://github.com/robinovitch61/hotstuff/blob/main/src/components/Canvas/hooks/usePanZoomCanvas.ts

@debugggy
Copy link

debugggy commented Mar 14, 2022

Do you know how can I add another box into the canvas?

@robinovitch61
Copy link
Author

robinovitch61 commented Mar 14, 2022

Do you know how can I add another box into the canvas?

The line of code that draws the box is context.fillRect. You should be able to add more stuff like that in that function and have it show up.

@debugggy
Copy link

debugggy commented Mar 14, 2022

Do you know how can I add another box into the canvas?

The line of code that draws the box is context.fillRect. You should be able to add more stuff like that in that function and have it show up.

Thanks for the quick reply. I tried just pasting that line 3 times but I still only have one box. Should I be doing something different?

@robinovitch61
Copy link
Author

robinovitch61 commented Mar 14, 2022

Do you know how can I add another box into the canvas?

The line of code that draws the box is context.fillRect. You should be able to add more stuff like that in that function and have it show up.

Thanks for the quick reply. I tried just pasting that line 3 times but I still only have one box. Should I be doing something different?

It might be the same box drawn on top of the same place multiple times :) you might be interested in this MDN tutorial, which helped me a lot when learning the fundamentals of the canvas api! https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial

@debugggy
Copy link

debugggy commented Mar 14, 2022

@gradywetherbee
Copy link

gradywetherbee commented Oct 10, 2022

What's the proper way to serialize and restore a view with this setup?

@robinovitch61
Copy link
Author

robinovitch61 commented Oct 10, 2022

Hi @gradywetherbee , good question! I implemented this myself at thermalmodel.com

image

Hitting "Saved View" restores the saved state, and "Overwrite Saved View" updates the stored state.

The TL;DR is you need to store the current zoom and offset in some application state and manage/overwrite it accordingly when the user performs some actions (hits buttons, presses certain keys, whatever)

The way I implemented it in my project is in this hook: https://github.com/robinovitch61/hotstuff/blob/main/src/components/Canvas/Canvas.tsx#L29

The view state is an input to the component. In the parent logic, there is the buttons for storing/reseting that state.

I won't really be able to help much with further implementation details, but hope that sets you off on the right track. Good luck!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment