How to Build a High‑Performance, Multi‑Touch Canvas Drawing Board from Scratch

This article walks through creating a fully customizable, high‑performance HTML5 canvas drawing board for online teaching, covering devicePixelRatio handling, responsive layout, touch and mouse event processing, drawing pipelines, multi‑touch support, erasing modes, caching strategies, undo/redo, and brush extensions, all with complete React code examples.

ELab Team
ELab Team
ELab Team
How to Build a High‑Performance, Multi‑Touch Canvas Drawing Board from Scratch

Preface

Running a system on a classroom screen inevitably involves a drawing board. Implementing an excellent board greatly assists online teaching. This guide builds a free‑drawing Canvas board from scratch with features such as customizable color and thickness, erasing, undo/redo, clearing, data storage, touch and mouse support, multi‑finger gestures, high‑performance rendering, responsive layout, and extensibility for chalk or brush‑stroke effects.

Customizable pen drawing, erasing, undo, redo, clear, data storage

Touch and mouse support, multi‑finger gestures

High‑performance rendering without frame drops

Responsive layout without fixed width/height

Extensible for chalk‑style or brush‑stroke pens

devicePixelRatio

The device pixel ratio (dpr) is essential for any canvas; ignoring it makes drawings blurry. Proper dpr handling involves obtaining window.devicePixelRatio, listening for changes (e.g., moving between screens or zooming), adjusting canvas width/height, and scaling the canvas context.

export function useDevicePixelRatio() {
  const [dpr, setDpr] = useState(window.devicePixelRatio);
  useEffect(() => {
    const list = matchMedia(`(resolution: ${dpr}dppx)`);
    const update = () => setDpr(window.devicePixelRatio);
    list.addEventListener('change', update);
    return () => list.removeEventListener('change', update);
  }, [dpr]);
  return { dpr };
}

// Scale context
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);

Canvas element should be defined with dpr‑adjusted dimensions:

<canvas
  width={dimension.width * dpr}
  height={dimension.height * dpr}
  style={{ width: dimension.width, height: dimension.height }}
/>

Responsive Layout

HTML5 canvas requires explicit width and height, but container size is often dynamic. Use an outer responsive container monitored by ResizeObserver to detect size changes and update the canvas accordingly.

<div className="container">
  <!-- 1. Responsive container -->
  <div className="canvas">
    <!-- 3. Canvas detached from document flow -->
    <canvas width={dimension.width} height={dimension.height} />
  </div>
  <div className="content">
    <!-- 2. Content overlay -->
    {children}
  </div>
</div>
.container { position: relative; }
.canvas {
  touch-action: none; /* disable default touch handling */
  user-select: none;
  position: absolute;
  width: 0; height: 0; left: 0; top: 0;
}
.content { position: relative; pointer-events: none; }

Obtaining Drawing Events

Events should be listened on document rather than the canvas to avoid interruptions when the pointer leaves the canvas area or interacts with overlapping elements. Use touchstart to activate subsequent touchmove handling.

Calculating Canvas‑Relative Coordinates

const { clientX, clientY } = event.changedTouches[0];
const { x, y } = canvas.getBoundingClientRect();
const point = { x: clientX - x, y: clientY - y };
When CSS transforms are applied, the above calculation becomes inaccurate; handling transformed canvases would require matrix math, which is beyond the current scope.

Pointer‑to‑Touch Translation

Mouse and touch events differ; the board prioritises touch but must also handle pointer events. Convert non‑touch pointer events to synthetic touch events when needed.

export function pointerToTouchInit(e) {
  const { clientX, clientY, pageX, pageY, target, screenX, screenY } = e;
  return { clientX, clientY, pageX, pageY, target, screenX, screenY,
    force: 1, radiusX: 0, radiusY: 0, identifier: Infinity, rotationAngle: 0 };
}

export function pointerToTouchAdapter(getEventHandler) {
  return (e) => {
    if (e.pointerType === 'touch') return;
    const { touchStart, touchMove, touchEnd, touchCancel } = getEventHandler();
    const touchInit = mouseToTouchInit(e);
    const init = { touches: [new Touch(touchInit)], changedTouches: [new Touch(touchInit)] };
    const typeMap = {
      pointerdown: ['touchstart', touchStart],
      pointermove: ['touchmove', touchMove],
      pointerup: ['touchend', touchEnd],
      pointercancel: ['touchcancel', touchCancel],
    };
    const { type } = e;
    if (!(type in typeMap)) return;
    const [newEvent, handler] = typeMap[type];
    const touchEvent = new TouchEvent(newEvent, init);
    handler(touchEvent);
  };
}

Initial Drawing

After obtaining canvas‑relative points, connect them into lines and render.

const ctx = canvas.getContext('2d');
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
let prev = { offsetX: 0, offsetY: 0 };
// touchstart
ctx.beginPath();
prev.offsetX = touch.offsetX;
prev.offsetY = touch.offsetY;
// touchmove
ctx.moveTo(prev.offsetX, prev.offsetY);
ctx.lineTo(touch.offsetX, touch.offsetY);
// touchend
ctx.lineWidth = 4;
ctx.strokeStyle = '#66ccff';
ctx.stroke();

Drawing Pipeline

Canvas output is volatile; resizing destroys the context, and undo/redo require storing drawing data rather than the rendered bitmap. A CanvasDescriptor class manages paths, pending and committed drawables, and a caching mechanism.

export class CanvasDescriptor {
  id: string;
  config: CanvasConfigType;
  mainCanvas: HTMLCanvasElement | null = null;
  pending: Drawable[] = [];
  committed: Drawable[] = [];
  // ... constructor and draw() implementation that clears the canvas,
  // draws cache, committed drawables, pending drawables, and indicators.
}

Data Structures

Define a base Drawable abstract class, a RawDrawable that records events, and concrete drawables such as PathDrawable, CacheDrawable, ClearDrawable, etc.

export abstract class Drawable {
  ctx: CanvasRenderingContext2D;
  constructor(ctx: CanvasRenderingContext2D) { this.ctx = ctx; }
  abstract draw(): void;
}

export type TrackedDrawEvent = { top: number; left: number; force: number; time: number };

export class RawDrawable extends Drawable {
  events: TrackedDrawEvent[] = [];
  #startTime: number | null = null;
  #endTime: number | null = null;
  track(touch: Touch) { /* store event with scaling */ }
  commit() { this.#endTime = performance.now(); }
  draw() { throw new Error('I don\'t know how to draw'); }
}

Multi‑Finger Drawing

Maintain a Map<number, RawDrawable> called acceptedTouches keyed by Touch.identifier. On touchstart create a new PathDrawable, store it, and track events; on touchmove update the corresponding drawable; on touchend commit and move it to the committed list.

Erasing

Two erasing modes are supported: path‑based erasing (remove whole paths) and bitmap erasing (use globalCompositeOperation = 'destination-out' to clear pixels). The PathDrawable draw method switches composite mode based on its type configuration.

Performance Optimisation

Repeatedly redrawing all committed drawables leads to linear slowdown. Introduce a cache canvas that stores already rasterised drawables. Only newly added drawables are rendered to the main canvas, then the cache is updated in batches (buffer size 2, minimum threshold 2). This keeps rendering time bounded.

class CacheDrawable extends Drawable {
  draw() {
    const { ctx, desc } = this;
    if (!desc.rasterizedLength || !ctx) return;
    const { mainCanvas, cacheCanvas } = desc;
    if (!mainCanvas || !cacheCanvas) return;
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(cacheCanvas, 0, 0, mainCanvas.clientWidth, mainCanvas.clientHeight);
  }
  update() {
    const { desc } = this;
    if (desc.committedDrawable.length >= desc.rasterizedLength + BUFFER_SIZE + MIN_THRESHOLD) {
      const before = desc.rasterizedLength;
      const after = desc.committedDrawable.length - BUFFER_SIZE;
      const toRasterize = desc.committedDrawable.slice(before, after);
      toRasterize.forEach(p => p.draw());
      const canvas = desc.mainCanvas!;
      const cacheCanvas = desc.cacheCanvas!;
      const cacheCtx = cacheCanvas.getContext('2d')!;
      cacheCtx.clearRect(0, 0, cacheCanvas.width, cacheCanvas.height);
      cacheCtx.drawImage(canvas, 0, 0, cacheCanvas.width, cacheCanvas.height);
      desc.rasterizedLength = after;
    }
  }
}

Undo / Redo / Clear

Undo/redo is handled by an afterUndoLength pointer that limits the visible length of the committed array. When undoing past the cached length, the cache is discarded and the canvas is fully redrawn. Clear operations are represented by a special ClearDrawable that calls ctx.clearRect.

export class ClearDrawable extends Drawable {
  draw() {
    const { ctx } = this;
    if (!ctx) return;
    const { canvas } = ctx;
    ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
  }
}

Brush Extensions

Beyond simple pen strokes, the framework supports shape drawing, brush‑stroke effects, and chalk‑style rendering. Chalk rendering uses an off‑screen canvas, a seeded pseudo‑random generator for reproducible texture, and clears random rectangles to simulate chalk dust.

function mulberry32(a) {
  return function() {
    let t = (a += 0x6d2b79f5);
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

export class ChalkDrawable extends RawDrawable {
  constructor(ctx, config) {
    super(ctx);
    this.config = { ...config };
    this.seed = Number(`${Math.random()}`.slice(2));
  }
  draw(events) {
    const { ctx, config } = this;
    const { lineWidth, color } = config;
    const { clientWidth, clientHeight, width, height } = ctx.canvas;
    const offscreen = new OffscreenCanvas(width, height);
    const offCtx = offscreen.getContext('2d');
    offCtx.scale(width / clientWidth, height / clientHeight);
    const original = Color(color);
    offCtx.fillStyle = original.setAlpha(0.5).toHex8String();
    offCtx.strokeStyle = original.setAlpha(0.5).toHex8String();
    offCtx.lineWidth = lineWidth;
    offCtx.lineCap = 'round';
    let xLast = null, yLast = null;
    const random = mulberry32(this.seed);
    function drawPoint(x, y) {
      if (xLast == null || yLast == null) { xLast = x; yLast = y; }
      offCtx.strokeStyle = original.setAlpha(0.4 + random() * 0.2).toHex8String();
      offCtx.beginPath();
      offCtx.moveTo(xLast, yLast);
      offCtx.lineTo(x, y);
      offCtx.stroke();
      const length = Math.round(Math.sqrt(Math.pow(x - xLast, 2) + Math.pow(y - yLast, 2)) / (5 / lineWidth));
      const xUnit = (x - xLast) / length;
      const yUnit = (y - yLast) / length;
      for (let i = 0; i < length; i++) {
        const xCurrent = xLast + i * xUnit;
        const yCurrent = yLast + i * yUnit;
        const xRandom = xCurrent + (random() - 0.5) * lineWidth * 1.2;
        const yRandom = yCurrent + (random() - 0.5) * lineWidth * 1.2;
        offCtx.clearRect(xRandom, yRandom, random() * 2 + 2, random() + 1);
      }
      xLast = x; yLast = y;
    }
    (events ?? this.getEvents()).forEach(e => drawPoint(e.left, e.top));
    ctx.globalCompositeOperation = 'source-over';
    ctx.drawImage(offscreen, 0, 0, clientWidth, clientHeight);
  }
}

Overall Architecture

The board consists of a CanvasDescriptor that holds configuration, dimension detection, pending/committed drawables, caching, and state management (normal, locked, hidden). It exposes methods for undo, redo, clear, and registration with a higher‑level context that can coordinate multiple boards.

Canvas with DPR handled correctly
Canvas with DPR handled correctly
Canvas without DPR handling (blurred)
Canvas without DPR handling (blurred)

By following this guide, developers can create a robust, extensible drawing board suitable for online education, collaborative whiteboards, or any application requiring freehand input.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CanvastouchdrawingUndo Redo
ELab Team
Written by

ELab Team

Sharing fresh technical insights

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.