Zoom and Drag for Html Canvas

Tom Cantwell
3 min readJan 1, 2021

--

This is a continuation of my series on making a Pixel Art app with an Undo function. This time I’m freeing up the canvas to make it zoomable and draggable, as this is a basic function of all art applications. You can use the app here.

First of all, we need to add a new variable to keep track of the zoom level, as well as offset variables to determine coordinates that the offscreen canvas itself is drawn at on the onscreen canvas.

//Set onscreen canvas and its context
let onScreenCVS = document.getElementById("onScreen");
let onScreenCTX = onScreenCVS.getContext("2d");
//improve sharpness
let ocWidth = onScreenCVS.width;
let ocHeight = onScreenCVS.height;
let sharpness = 4;
let zoom = 1;
onScreenCVS.width = ocWidth * sharpness;
onScreenCVS.height = ocHeight * sharpness;
onScreenCTX.scale(sharpness * zoom, sharpness * zoom);
const state = {
...,
xOffset: 0,
yOffset: 0,
lastOffsetX: 0,
lastOffsetY: 0,
}
//zoom buttons
let zoomCont = document.querySelector(".zoom");
zoomCont.addEventListener('click', handleZoom);

I like to keep the zooming centered, so there’s some math involved to calculate the offsets. To keep things aligned for the custom cursors, I make sure that the offsets are always in increments of the ratio of the scale between the onscreen and offscreen canvases.

function handleZoom(e) {
if (e.target.closest(".square")) {
let zoomBtn = e.target.closest(".square");
let z;
if (zoomBtn.id === "minus") {
z = 0.8;
zoom *= z;
state.xOffset += ocWidth / 10 / zoom;
state.yOffset += ocHeight / 10 / zoom;
} else if (zoomBtn.id === "plus") {
z = 1.25;
state.xOffset -= ocWidth / 10 / zoom;
state.yOffset -= ocHeight / 10 / zoom;
zoom *= z;
}
//re scale canvas
onScreenCTX.scale(z, z);
state.xOffset = Math.floor(
state.xOffset - (state.xOffset % (ocWidth / offScreenCVS.width))
);
state.yOffset = Math.floor(
state.yOffset - (state.yOffset % (ocHeight / offScreenCVS.height))
);
state.lastOffsetX = state.xOffset;
state.lastOffsetY = state.yOffset;
renderImage();
}
}

Pretty much everything that uses the onscreen canvas dimensions in their calculations now needs to include the zoom variable as well. For example:

function handleMouseMove(e) {
state.event = "mousemove";
//currently only square dimensions work
state.trueRatio = onScreenCVS.offsetWidth / offScreenCVS.width * zoom;
state.ratio = ocWidth / offScreenCVS.width * zoom;
//coords
state.mox = Math.floor(e.offsetX / state.trueRatio);
state.moy = Math.floor(e.offsetY / state.trueRatio);
state.mouseX = state.mox - (state.xOffset / state.ratio * zoom);
state.mouseY = state.moy - (state.yOffset / state.ratio * zoom);
//Hover brush
state.onX = state.mox * state.ratio / zoom;
state.onY = state.moy * state.ratio / zoom;
if (state.clicked) {
//run selected tool step function
state.tool.fn();
} else {
//only draw preview brush when necessary
if (state.onX !== state.lastOnX || state.onY !== state.lastOnY) {
onScreenCTX.clearRect(0, 0, ocWidth / zoom, ocHeight / zoom);
drawCanvas();
renderCursor();
state.lastOnX = state.onX;
state.lastOnY = state.onY;
}
}
}

For the drag tool, I actually need to add a new tool to the library:

 const tools = {
...,
grab: {
name: "grab",
fn: grabSteps,
brushSize: 1,
options: []
}
}

To keep the canvas aligned with the mouse, we need to keep track of the last registered offset and use it with the onscreen mouse coordinates and last onscreen mouse coordinates.

function grabSteps() {
switch (state.event) {
case "mousemove":
//only draw when necessary, get color here too
state.xOffset = state.onX - state.lastOnX + state.lastOffsetX;
state.yOffset = state.onY - state.lastOnY + state.lastOffsetY;
renderImage();
break;
case "mouseup":
state.lastOffsetX = state.xOffset;
state.lastOffsetY = state.yOffset;
state.lastOnX = state.onX;
state.lastOnY = state.onY;
break;
case "mouseout":
state.lastOffsetX = state.xOffset;
state.lastOffsetY = state.yOffset;
break;
default:
//do nothing
}
}

--

--