-
-
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.
Thanks!