Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save robinovitch61/483190546bf8f0617d2cd510f3b4b86d to your computer and use it in GitHub Desktop.
Save robinovitch61/483190546bf8f0617d2cd510f3b4b86d to your computer and use it in GitHub Desktop.
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>
);
}
@debugggy
Copy link

@gradywetherbee
Copy link

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

@robinovitch61
Copy link
Author

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!

@YevhenTarashchyk
Copy link

@robinovitch61 in my case 'canvasHeight' and 'canvasWidth" are not static ... they change depending on the resize. What I need to do to make it work right ? now it resets to prev state each time I resize parent element

@robinovitch61
Copy link
Author

@robinovitch61 in my case 'canvasHeight' and 'canvasWidth" are not static ... they change depending on the resize. What I need to do to make it work right ? now it resets to prev state each time I resize parent element

Hi @YevhenTarashchyk , I can point you to the react component's height and width state that powers the resizability of https://thermalmodel.com/. Hopefully that helps. In general, you'll want to do something like this:

https://github.com/robinovitch61/hotstuff/blob/main/src/components/Canvas/Canvas.tsx#LL98-L100C63

@YevhenTarashchyk
Copy link

YevhenTarashchyk commented Dec 27, 2022 via email

@YevhenTarashchyk
Copy link

@robinovitch61 Could u pls help me a little ? https://codesandbox.io/p/github/YevhenTarashchyk/EasyTextureUI_components/draft/patient-cherry
When I resize element the image position in canvas is a little bit off (it is moving)... What should I do ?
Here is a vid for better understanding

zoom.issue.mp4

@robinovitch61
Copy link
Author

@YevhenTarashchyk I don't have time to dig in unfortunately, but you've done a good job demonstrating and reproducing your issue! I'm sure someone on stack overflow would be down to help out if you post a question there.

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