Tutorial: Drawable and Pannable-Zoomable Canvas in Vanilla JS
Introduction
A few weeks ago, I needed to make a canvas freehand drawing tool for a designers’ Fiverr app in SvelteKit. Since the requirements were a bit complex and subject to change, I hesitated to rely on an npm package, as it might not fully align with my client’s needs.
Instead of choosing the uncertain route, I chose the more challenging path and decided to build a quick MVP myself first, following Harrison Milbradt’s articles: An exploration into free drawing in HTML canvas and Panning and Zooming in HTML Canvas. I highly recommend reading them — they’re incredibly insightful.
This tutorial essentially combines the code from both articles and enhances it with additional functionality. The contents of this tutorial are as follows:
- The Goal: This section outlines what we aim to build, including a brief description of additional potential features, some of which are explored in Harrison’s articles.
- The Implementation: Here, we discuss the technical essence of our solution, providing a high-level understanding of the approach before delving into the specifics.
- The Detailed Guide: This part consists of six steps: creating two canvases, drawing a background image on the background canvas, implementing drawing and erasing, implementing panning, and implementing zooming.
The Goal:
The goal is to create a canvas with an image already drawn on it. On top of this image, the designer should be able to freely draw lines and erase any of their previous drawings using an erase tool.
Additionally, the designer should have the ability to zoom in and pan around the image, enabling them to focus on specific parts and interact with the draw tool.
This feature can be extended to include functionalities such as different pen colors, adjustable pen widths, a pen drawing cursor indicator, and the ability to draw shapes. However, these enhancements are straightforward to implement once the foundational functionality is in place.
The Implementation:
At its core, our solution involves two canvases working seamlessly together. The background canvas will display the image we want to draw on, ensuring that drawing and erasing lines do not affect the image’s pixels. The foreground canvas, referred to as the drawing layer canvas, will handle interactions like drawing and erasing lines.
Mouse event handlers (e.g., mouseup, mousedown, mousemove, and mousewheel) will update the canvas’s context based on user actions. Any panning or zooming on the drawing layer canvas must be precisely mirrored in the background canvas to avoid misalignment of drawn lines.
Additionally, the contexts of both canvases need to stay synchronized so that any drawn or erased lines scale and pan correctly, maintaining a visually consistent and accurate result. This synchronization is the essence of our solution.
The Detailed Guide:
Step 1: Create the 2 Canvases
We will proceed by creating two canvases, as described below:
<canvas width="500" height="500" id="backgroundLayerCanvas"></canvas>
<canvas width="500" height="500" id="drawingCanvas"></canvas>
Let’s style them a bit as well to enhance their appearance, and to have one layer over the other:
#drawingCanvas {
position: absolute;
top: 0;
left: 0;
z-index: 1;
border: 2px solid black;
}
#backgroundLayerCanvas {
border: 2px solid black;
background-color: greenyellow;
}
The result:
Step 2: Draw the Image on the Background Canvas
Let’s write some JavaScript to retrieve the elements in our script and draw a random duck image onto the background canvas:
const backgroundCanvas = document.getElementById('backgroundLayerCanvas')
const backgroundCtx = backgroundCanvas.getContext('2d')
const canvas = document.getElementById('drawingCanvas')
const ctx = canvas.getContext('2d')
imageObj = new Image();
imageObj.onload = function () {
backgroundCtx.drawImage(imageObj, 0, 0);
}
const duckImageSrc = 'data:image/jpeg;base64,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'
imageObj.src = duckImageSrc; // You can have this come from an uploaded image as well
The result:
Step 3: Implement Drawing and Erasing of Lines on Drawing Layer Canvas
First, let’s define a drawLine
function, which will take the x- and y-coordinates of the starting and ending positions to draw a line:
const drawLine = (x, y, previousPosX, previousPosY) => {
ctx.beginPath(); // this will be drawing lines on context of drawing layer canvas only (ctx)
ctx.moveTo(previousPosX, previousPosY);
ctx.lineTo(x, y);
ctx.strokeStyle = penColor;
ctx.lineWidth = penWidth;
ctx.lineCap = 'round';
ctx.stroke();
}
Similarly, let’s define a function eraseLine
for erasing a line, which will take the same arguments as the drawLine
function:
const eraseLine = (x, y, previousPosX, previousPosY) => {
ctx.save()
ctx.beginPath()
ctx.moveTo(previousPosX, previousPosY)
ctx.lineTo(x, y)
ctx.globalCompositeOperation = 'destination-out'
ctx.lineWidth = penWidth
ctx.lineCap = 'round'
ctx.stroke()
ctx.restore()
}
When we try to draw a line, we trigger the mousedown
event once with our mouse, followed by several mousemove
events as we drag the cursor around. We only need to track the mousemove
events that occur between the mousedown
and mouseup
events. Once the mouseup
event occurs, we will detach the mousemove
event listener from the canvas.
Before defining those functions, we need some variables to keep track of the x- and y-coordinates that will update with every mouse event. Let’s initialize these variables, along with some others, as follows:
// NEW CODE
let drawModeType = 'freehand'
let previousXDrawing = 0, previousYDrawing = 0; // these are previous mouse event's X and Y coordinate positions for the canvas TODO: resolve confusion
let lines = [];
let isDrawingMode = true
const penWidth = 5
const penColor = 'red'
// OLD CODE
const backgroundCanvas = document.getElementById('backgroundLayerCanvas')
const backgroundCtx = backgroundCanvas.getContext('2d')
const canvas = document.getElementById('drawingCanvas')
const ctx = canvas.getContext('2d')
Now, let’s go ahead and define those functions:
const onMouseDown = (e) => {
// This is my attempt to make the drawLine logic work
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Create a DOMPoint for the pixel coordinates
const t = ctx.getTransform().inverse(); // Get the inverse of the transformation applied to the canvas context
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Use it to calculate context coordinates for your pixel point
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
canvas.addEventListener("mousemove", onMouseMove);
}
const onMouseMove = (e) => {
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Create a DOMPoint for the pixel coordinates
const t = ctx.getTransform().inverse(); // Get the inverse of the transformation applied to the canvas context
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Use it to calculate context coordinates for your pixel point
const xToDraw = adjustedX
const yToDraw = adjustedY
if (e.shiftKey) {
eraseLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
lines.push({ x: xToDraw, y: yToDraw, previousX: previousXDrawing, previousY: previousYDrawing, isEraseLine: true }) // we somehow need to remember that this is a erased line. and handle it that way
} else {
if (drawModeType === 'freehand') {
drawLine(xToDraw, yToDraw, previousXDrawing, previousYDrawing)
lines.push({ x: xToDraw, y: yToDraw, previousX: previousXDrawing, previousY: previousYDrawing, isEraseLine: false })
}
}
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
}
const onMouseUp = (e) => {
canvas.removeEventListener("mousemove", onMouseMove);
}
Let’s also attach the event handlers to their respective event listeners:
// Adding mousedown event listener to drawing canvas
canvas.addEventListener("mousedown", onMouseDown)
// Adding mouseup event listener to drawing canvas
canvas.addEventListener("mouseup", onMouseUp)
The result:
Step 4: Implement Panning on both the Canvases
Before proceeding, we need to toggle between drawing and view modes. Both panning and drawing require a mousedown
event followed by a series of mousemove
events, which can lead to conflicts between the two. The simplest solution is to separate them into distinct modes:
<body>
<canvas width="500" height="500" id="backgroundLayerCanvas"></canvas>
<canvas width="500" height="500" id="drawingCanvas"></canvas>
<label for="">Drawing Mode</label>
<input type="checkbox" checked onchange="toggleDrawMode()">
<script src="canvas.js"></script>
</body>
<script>
function toggleDrawMode() {
isDrawingMode = !isDrawingMode
}
...
</script>
Let’s initialize additional variables to keep track of the x- and y-coordinates for panning separately:
let previousX = 0, previousY = 0; // these are previous mouse event's X and Y coordinate positions for the canvas
Let’s modify the mousedown
event handler:
const onMouseDown = (e) => {
// NEW CODE: This is needed for ensuring smooth panning
previousX = e.clientX;
previousY = e.clientY;
// OLD CODE:
if (isDrawingMode) {
var bounding = canvas.getBoundingClientRect();
var x = e.clientX - bounding.left;
var y = e.clientY - bounding.top;
const p = DOMPoint.fromPoint({ x, y }); // Create a DOMPoint for the pixel coordinates
const t = ctx.getTransform().inverse(); // Get the inverse of the transformation applied to the canvas context
const { x: adjustedX, y: adjustedY } = t.transformPoint(p); // Use it to calculate context coordinates for your pixel point
previousXDrawing = adjustedX
previousYDrawing = adjustedY
}
}
To maintain consistency, we also need the viewportTransform
, which will track all the transformations and actions performed on the canvases:
const viewportTransform = {
x: 0,
y: 0,
scale: 1
}
Finally, let’s modify the mousemove
event handler to account for panning:
const onMouseMove = (e) => {
// OLD CODE ...
if (isDrawingMode) {
...
} else { // NEW CODE ...
updatePanning(e)
render()
}
}
Some explanation is needed for these critical methods: updatePanning
and render
:
updatePanning
: This function updates theviewportTransform
object, which tracks the transformations applied to the canvas. It ensures that when panning occurs, the positions of the already drawn pixels are correctly adjusted. This way, when we draw new lines, they will appear exactly where the cursor is positioned, taking into account any prior panning.render
: Therender
function first resets both canvases by clearing any previously drawn content. It then sets the transform matrix of the drawing layer canvas to reflect any actions performed during panning (and zooming, if applicable). Next, it redraws the background image on the background canvas and re-renders the previously drawn lines on the drawing layer canvas. Remember thelines.push
call from earlier? This is exactly why we stored the lines—to redraw them accurately during each render!
const updatePanning = (e) => {
const localX = e.clientX;
const localY = e.clientY;
viewportTransform.x += localX - previousX;
viewportTransform.y += localY - previousY;
previousX = localX;
previousY = localY;
}
const render = () => {
// Code for drawing layer canvas
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
// Code for background layer canvas
backgroundCtx.setTransform(1, 0, 0, 1, 0, 0);
backgroundCtx.clearRect(0, 0, canvas.width, canvas.height);
backgroundCtx.setTransform(viewportTransform.scale, 0, 0, viewportTransform.scale, viewportTransform.x, viewportTransform.y);
backgroundCtx.drawImage(imageObj, 0, 0);
// Ok, so we DO need to redraw the lines. That means we DO need to KEEP TRACK of them!
// This drawing just needs absolute figures, the same as they were when the line was being made! The viewportTransform will automatically adjust shit here and there.
lines.forEach((line, idx) => {
if (line.isEraseLine) {
eraseLine(line.x, line.y, line.previousX, line.previousY)
} else {
drawLine(line.x, line.y, line.previousX, line.previousY)
}
});
}
Here’s a TypeScript interface for the Line
object:
interface Line {
x: number; // The line's end point's x coordinate
y: number; // The line's end point's y coordinate
previousX: number; // The line's start point's x coordinate
previousY: number; // The line's start point's y coordinate
isEraseLine: boolean; // we will call either the drawLine or eraseLine method based on this
}
The end result:
Step 5: Implement Zooming on both the Canvases
Finally, we need to implement zooming as well. Let’s define a function to update the zooming properties of the canvas context:
const updateZooming = (e) => {
const oldX = viewportTransform.x;
const oldY = viewportTransform.y;
const localX = e.clientX;
const localY = e.clientY;
const previousScale = viewportTransform.scale;
const newScale = viewportTransform.scale += e.deltaY * -0.01;
const newX = localX - (localX - oldX) * (newScale / previousScale);
const newY = localY - (localY - oldY) * (newScale / previousScale);
viewportTransform.x = newX;
viewportTransform.y = newY;
viewportTransform.scale = newScale;
}
We also need to define an event listener that uses the updateZooming
method:
const onMouseWheel = (e) => {
updateZooming(e)
render()
}
We also want to prevent zooming out until the zoom level reaches zero, as this could introduce bugs (such as drawings not appearing at all). To handle this, we introduce an isZoomAllowed
method:
// If we are trying to zoom out to a level lesser than zoom level 1, then we will not do anything
const isZoomAllowed = (viewportTransform, deltaY) => {
return viewportTransform.scale + deltaY * -0.01 >= 1
}
const onMouseWheel = (e) => {
if (isZoomAllowed(viewportTransform, e.deltaY)) {
updateZooming(e) // just like updatePanning changes the transform matrix, so does updateZooming
render() // after making necessary adjustments to the transform matrix, we need to redraw everything, hence render is called again
}
}
// Adding mousewheel event listener to drawing canvas
canvas.addEventListener("wheel", onMouseWheel);
The result:
Ending
You can find all the code for this hosted here. I am planning to create an npm package (Svelte-specific) for this functionality soon, so expect its release in a few weeks.
For a deeper understanding of how the drawLine
and eraseLine
methods work with the canvas API, I highly recommend checking out Harrison's articles. They were instrumental in enhancing my understanding of the topic:
Happy coding!