-
-
Save robinovitch61/483190546bf8f0617d2cd510f3b4b86d to your computer and use it in GitHub Desktop.
| // 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> | |
| ); | |
| } |
What's the proper way to serialize and restore a view with this setup?
Hi @gradywetherbee , good question! I implemented this myself at thermalmodel.com
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!
@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 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
@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
@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.

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