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.
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.
By following this guide, developers can create a robust, extensible drawing board suitable for online education, collaborative whiteboards, or any application requiring freehand input.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
