Tutorial: Drawable and Pannable-Zoomable Canvas in Vanilla JS

Abdullah Ahmad
10 min readDec 22, 2024

--

A sample drawing tool (don’t worry, we aren’t building this completely)

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:

  1. 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.
  2. The Implementation: Here, we discuss the technical essence of our solution, providing a high-level understanding of the approach before delving into the specifics.
  3. 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:

Don’t mind the non-overlapping borders. It was a minor CSS issue in an app made for R&D, which I didn’t have the time to fix because it didn’t occur in the main app I worked on. I am pretty sure you can fix this yourself.

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,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAdHx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5OjcBCgoKDQwNGg8PGjclHyU3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3N//AABEIAJQAlAMBIgACEQEDEQH/xAAcAAEAAQUBAQAAAAAAAAAAAAAABgIDBAUHAQj/xAA7EAABAwIFAQcCAwcDBQEAAAABAAIDBBEFBhIhMUEHEyJRYXGBkaFCscEUFSMyUtHwYoKiM0NzkuEI/8QAGQEBAAMBAQAAAAAAAAAAAAAAAAECAwQF/8QAIhEBAQACAgICAgMAAAAAAAAAAAECEQMxEiEEQRNRMjNx/9oADAMBAAIRAxEAPwDuKIiAiIgIiICIiAiIgLxeqiWRsUbpJHBrGgucT0A5QaHNeb8LyxDGa55fUTf9Gmj/AJ325PoPU/nsoce1d7DrkwyMRE7ASnVb6LmWYa3Ec0Y3iGLENcJXEU4J2ZECdDR8bn1JWBJiQ0CJ5j1htntad2bkb+vHnyFa6kZXO26xfQWWs+YNjszaaN7qerdxFNbxejXcH2UrBuvkxk7mG4lIc0hzHtO4PQ39OV9NZNxR2NZYw3EJBaWaAd4P9Y2d9wVGvW15W6REULCIiAiIgIiICIiAiIgIiICiParXOosj4iI3lj6kNpWuHTvHBp+xKly5t29VDocn07GOsZa6MD1sHO/REXpzuiEMVK2GNv8ADY223VYeJUcEzbhrQL3u3qVoaXFSwd2/byKrlxSZjSRaTT+EHcqttefh8fOZ72zMOwSpxeWrZh+kvpYhIWO21gutYHi+xPwu0di0tQMrS0lWxzJKepeGse0ghpsfzuorkLBZKPCJ6qpcx1RiIDgWC7WsAOn35J6c2UnylV1OHVLGV8sby+7HljNIG+2x46LknzMfyXC37ep+K+MroaKlrgW3aQQeCFUu1QREQEREBERAREQEREBERAXK/wD9BOJwDCIywljsQuXeREb7D5ufouqLmPbwC7A8IFwG/vDe/wD43oi9OC1FO2Jjmkl++3ote4PbYh556FbPEJWC4DtTgbEk3ssSOPw6nEA+RKrEY9Po3svwEMyFg9QJZWVcsRlL3PLwdTiQC0m1tNhtZUYrDNR1ZFXRPhcb6Zo7uid7Hp7Fb3sxnjqMgYC+EgtbSNjOnzbdp+4KlBF1z8vxOPku61x5Mo5hT57iwFjXVpfJR98IXu6s53t5C1l0miqo6ymjqIXB0cgu0jqsHGMuYNjbGtxbDKWrDTdveRgkfPK2UUbYmNZG1rWNFg1osAF0YY+OOlPtWiIrAiIgIiICIiAiIgIi8uEHqi+e8uUeYoMObiD5W09JVtlf3brEggtsT0G43Uouot2lVoo8n4gGv0STx9yw+RP/AMuoqZN1z/MlT2XPlZTzYO06DcS0gLL+d3MNz8q/SydmGmKfD8Hhd3cnMl2hptydbvsVzJsbWCzwG22NxsvQRpcGWvbbSNx+qY1bLCR9H5PxrCsYwy+DiOKOJxa6BukGMkk7tHF9z8rfr5/7N8zHAsaj/a5HGkqLRS3cSGXOzrcbbfF138EHhSprT1ERAREQEREBEXhcALkiyD1FizV9PCPFK0nyBWqqsxwQtvrjYPN7gEG9LgNri6sS1kMQJcdh5KITZtpy116tmvq5vl7rTVeaaMNHj7w9ASo8onVTmqxuFsbv2cgvt4S4bXUarMZzDZ+maldbfTT+En4d1+VHIMYfVPBZs2/krs1XKBsXc72F7qdymmbFmitcJXfvCVjgbRxyMBP25Uez3i+J1lFR0ldPHIAXSPsN3cW2t7q3SOe/FJdYOoybkjqfJavN0rpMWfuC1ttBI24H5FZ5VphPaL1Tg1xL22ttfj81Swgahc2udS9qg/SWNJtaxHPVU041dNiFaGVX6dzg4jW4nqL9PJdx7K8xnE8JOHVb71dE0Btzu+PofUjj6ea4doLXhwF+lxst1lnGpcCxqmxOIamRG0zWfijOzhb7+4Csr2+kF6rVPNHPBHNC8PjkaHMcDcEHcFXUUEREBRLMefcJwbBJ8TjElX3dWaJkUY095MDYtBO1hY7+ilh4XzLm6qMeDVmXqppZV4VjksoufDNHJqJ+RcfDkHboM1VUdAybGaKnw6oO5pW1HfuaOlyAAD6XKiOYu0K+pkbtI9CQoI7MbsTwmJ0els0TdE0Q5B/qF+QfPzuohX1cj3+Jxv5AXWdtt00mM1t0KPP889QYXSAAsNiBurNVg+P45KyowyN8jHt3mmlDWfA5V3IXZ7FUQRYvjmp93Nkp6VrvDsb3f5+y6tFG0NDWtAAGwGyt4+/aPL9Oc0fZ5XSRN/eONd0fxMpotX/J39luqfIGERWMslZM4cF05A/4gKXWDdlS5wHt6KdRXyrSxYJhtCwNipm2H9RLj91bdBFG4uhiaHeQFltKh12lxN/daupmDYy5vJNgOE9DTSh9LiFTIYw52gPsPRQzEqltTUukLgC/xWP8w/wKaYm10MFTOXESOYWi/U26LnURu5webAcO03v7fks722w6VTxAll2EtP8ALpAVmKIN4AG+922/zosmomaCL21OG1+SfO3CxZiS60jAHHkltvoFeK1dD2vd3cnJO3BXheYnOifbwja1jb1sOqw++a14byPRV1EwOiNsm+m5LW2AA53/AM3U2okd47H8YdieVjTyEmShmMIubksIDmH6OI/2qdrjXYLUufX4xAG6Gd1E7SDfe7v0K7KpimXYiIiGPUse4s7tzhY72XF+2LIVRJI3G8v0M00r3udXRMc6R7nHh4BcfazQu4WUczjlyXHaFzaDFq7C65o/h1FLUSMBPk9rSNQ+46IONdj2SKPMlRiVVjEU3c0jmQiLU5l5L3cD8CxH+r2XXDlnL1DUh+H4Hh1PK0Ed5HTtB32IvbyWJ2bZfrcrZeq6LEHNfXy1kk8shk1CS9gHA87gDne91uJqhpmcJG2PUHYhQLUkUMLGNjYGtaLBreAFbjbqu4m99grkxjEbi1/sL8q2x7QfDYDgg7WKbFL79ArEjiTbkgdAr7nCzdJuCOisuLJCXWAsOU2lgytc47C1vNYbo+9Lrt2bc6vILOqWaX2uVYg0sGl5uL8AbkKKnTnmcscc8GKBpjEM5DnHryLH7/RQkz2lN3F4O7SVK89YZLR4g+bvNUc5JtqsRsT7cC/ruoVM0gWPA2BBtqHmLqjWdMsTOld4XaXje7WW87qiefS0tANmn8LrW6e54WEXtNt7W4c3bbyVPenRdwvz4gbW/upRautmJcGNIdfre/2V9ziYw0yBurdzXm9vXyssFrhcbG3Ut4cP0WTGQbAu3ANr2FvqrVWOx9gdIWz4zVabN0RRDw26uP6rsagvY9gzsJydDLK3TNXvNS6/kQA37AH5KnSmKXsREUoEREFqWISehHB8lqKmmjD9FXHfmzwei3iokjbKwteA5p6FRYNBLFStgMMRMYcNng33KwLOim7pzncXLtPg3/z0W2r8Ie1pdQm550O6H0J/VRisfKyYtnc+JtrvYRv8XVL6WjaNe3vw1wsRe4ubhUvMfiAd6+S0EVTK9zKeQEkWLHA2J+N7nzWU6d34jyeo2UbNMx72k7jxc3BCwHys4vpud3DpvysWorhc2Av7rW1VdbceLzA6JtOmvzcG1VE58szmOaQ5jttrHqfn7rlNU10bzGTp0cDouk4jVsmjex5FnDfi30UMxbD2h+uN97bWN7j2Kfa86aE38RvfffdUDnkg+ivyw6NNiDc2tvz5Le4JkbM2OOH7Bg9SIz/3Z290wfLrX+Lq8VqPtaS7qetiAbqe9mmR6nNWJNqaqLRhFM7+M9wt3x/ob5+p6D1Uxyr2JxwvjqMyV4mDTf8AZKUFrT6OfyR7Ae665RUdPQU0dLRwshp4mhscUbbNaPIBSrausaGtDWtAaBYADgKpEUqiIiAiIgIiIPFj1tFTVkYZVQMkA4Lhu32PIWShQQvFsoxUzHVVFVSiJnifBJ4thv4TsQfquc1+HYxjGcqmnweqkllMRm7qSoLRta4ub254XdZ2CWGSN3D2kFQPItGyPM9ZVSN/ivpGsYfIBw1/fSufP1ySft28Mxy4M7e4ikuXczxRDvsHrNfUtljkv/6laquwjMVNS1FXPhVXBTQM7yV7rNAaOfUr6AWpzW0SZdxGJztIkgcy/uLfqtLjI5scrbI4hlPLVfnAzPoaimghiID3yFxdv5NA/VT/AAvskwqEh+K1tVWu2vGy0Uf28X3WB2UMGH4xPTNN2T02r5a7+xK6pdRx2ZY7afIx/Hn4tTheWMCwlxfh2E0cEp5kbEC8/wC47/dbeyXQG61c72yIiAiIgIiICIiAiIgIiIKSLC/kobl86Mb8O15ZGfG5t9giLm5/5Yu34v8AXyf4mYUQz6w1VTgOHPkkbT1lcWTCN2kkBjjz7hEW+Tlwtl3EFwyuqKHNYfTO06aVrtPS7udvhbquzjjLMW/Yo5o2xmMO1CIar29URcGOVmWpXt58eGUtyjFrMSxGsdC2oxGqLXzNY5rJO7BBI/psurU8TYYWxsLi1osNby4/U3JXiLvx6ePy9rqIisxEREBERB//2Q=='
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 the viewportTransform 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: The render 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 the lines.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 Lineobject:

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:

  1. Panning and Zooming in HTML Canvas
  2. An exploration into free drawing in HTML canvas

Happy coding!

--

--

No responses yet