-
-
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> | |
| ); | |
| } |
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.
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?
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
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.

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