Skip to content
Canvas SketchPad: Drawing, Touch, and Datasets
June 12, 2026· 12 min read
CanvasJavaScriptDatasets

Canvas SketchPad: Drawing, Touch, and Datasets

Build a Canvas 2D sketch pad with mouse and touch drawing, undo support, and a Node pipeline that exports ML-ready datasets.

A drawing app is one of the best ways to learn the Canvas 2D API. Strokes, touch input, undo, and image export each map directly to skills used in whiteboards, annotation tools, and signature pads.

This project goes one step further. Completed drawings are collected in the browser and converted with Node into a labeled dataset of JSON stroke files, PNG images, and a samples manifest.

How canvas strokes are modeled

A drawing is a list of paths. Each path is a list of x and y points from one press to one release. Rendering replays every path on each change, which keeps undo and reset trivial.

javascript
function drawPath(ctx, path, color = "#111827") {
  if (path.length === 0) return;
  ctx.strokeStyle = color;
  ctx.lineWidth = 3;
  ctx.lineCap = "round";
  ctx.lineJoin = "round";
  ctx.beginPath();
  ctx.moveTo(path[0][0], path[0][1]);
  for (let i = 1; i < path.length; i++) {
    ctx.lineTo(path[i][0], path[i][1]);
  }
  ctx.stroke();
}

function drawPaths(ctx, paths, color) {
  for (const path of paths) drawPath(ctx, path, color);
}

Why redraw everything instead of painting incrementally

Full redraws stay correct when paths are removed or reordered. For sketch-sized canvases the cost is negligible, and the state model remains a plain array that serializes to JSON without conversion.

How to build a SketchPad with mouse and touch

Pointer Events unify mouse, pen, and touch into one API. Pointer capture keeps the stroke alive even when the pointer moves fast or leaves the element briefly.

javascript
class SketchPad {
  constructor(container, size = 400) {
    this.paths = [];
    this.isDrawing = false;
    this.canvas = document.createElement("canvas");
    this.canvas.width = size;
    this.canvas.height = size;
    this.ctx = this.canvas.getContext("2d");
    this.onPointerDown = this.onPointerDown.bind(this);
    this.onPointerMove = this.onPointerMove.bind(this);
    this.onPointerUp = this.onPointerUp.bind(this);
    this.canvas.addEventListener("pointerdown", this.onPointerDown);
    this.canvas.addEventListener("pointermove", this.onPointerMove);
    window.addEventListener("pointerup", this.onPointerUp);
    container.appendChild(this.canvas);
  }
  onPointerDown(e) {
    this.canvas.setPointerCapture(e.pointerId);
    this.paths.push([this.toLocal(e)]);
    this.isDrawing = true;
    this.redraw();
  }
  onPointerMove(e) {
    if (!this.isDrawing) return;
    this.paths[this.paths.length - 1].push(this.toLocal(e));
    this.redraw();
  }
  onPointerUp() {
    this.isDrawing = false;
  }
  undo() {
    this.paths.pop();
    this.redraw();
  }
  reset() {
    this.paths = [];
    this.isDrawing = false;
    this.redraw();
  }
  toLocal(e) {
    const rect = this.canvas.getBoundingClientRect();
    const x = (e.clientX - rect.left) * (this.canvas.width / rect.width);
    const y = (e.clientY - rect.top) * (this.canvas.height / rect.height);
    return [Math.round(x), Math.round(y)];
  }
  redraw() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    drawPaths(this.ctx, this.paths);
  }
  destroy() {
    this.canvas.removeEventListener("pointerdown", this.onPointerDown);
    this.canvas.removeEventListener("pointermove", this.onPointerMove);
    window.removeEventListener("pointerup", this.onPointerUp);
  }
}

What each part does

  1. pointerdown starts a new path and captures the pointer to the canvas.
  2. pointermove appends local coordinates while the button is held.
  3. pointerup on the window always ends the stroke, even off-canvas.
  4. undo removes the last path and redraws; destroy removes listeners.

How to convert pointer position to canvas pixels

Client coordinates are relative to the viewport, while drawing needs canvas pixels. The bounding rect gives the offset, and the width ratio corrects for CSS scaling and high-DPI displays.

Rounding keeps stroke files small and stable. Integer coordinates are easier to normalize later when training or comparing drawings.

How to collect labeled drawings in the browser

The creator flow prompts for one label at a time, such as car, house, or tree. Each finished sketch is stored under its label with a session ID and student name.

javascript
const labels = ["car", "house", "tree", "fish", "clock"];
const session = Date.now();
const drawings = {};
let index = 0;

function collect(label, paths) {
  if (paths.length === 0) return false;
  drawings[label] = paths;
  return true;
}

function download(data, filename) {
  const blob = new Blob([JSON.stringify(data)], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  link.remove();
  URL.revokeObjectURL(url);
}

download({ session, student: "milan", drawings }, session + ".json");

The Blob download avoids server round-trips during collection. Each session file drops straight into the raw data folder for processing.

How to generate an ML-ready dataset with Node

Raw session files are inconvenient for training. The Node pipeline splits every drawing into its own JSON file, renders a matching PNG with node-canvas, and writes a samples manifest linking IDs to labels.

javascript
const fs = require("node:fs");
const path = require("node:path");
const { createCanvas } = require("canvas");
const { drawPaths } = require("./draw");

const RAW_DIR = "./data/raw";
const JSON_DIR = "./data/dataset/json";
const IMG_DIR = "./data/dataset/img";

function renderToPng(paths, outFile, size = 400) {
  const canvas = createCanvas(size, size);
  const ctx = canvas.getContext("2d");
  ctx.fillStyle = "#ffffff";
  ctx.fillRect(0, 0, size, size);
  drawPaths(ctx, paths);
  fs.writeFileSync(outFile, canvas.toBuffer("image/png"));
}

function buildDataset() {
  const samples = [];
  let id = 1;
  for (const file of fs.readdirSync(RAW_DIR)) {
    const { session, student, drawings } = JSON.parse(
      fs.readFileSync(path.join(RAW_DIR, file), "utf8")
    );
    for (const [label, paths] of Object.entries(drawings)) {
      fs.writeFileSync(path.join(JSON_DIR, id + ".json"), JSON.stringify(paths));
      renderToPng(paths, path.join(IMG_DIR, id + ".png"));
      samples.push({ id, label, student_name: student, student_id: session });
      id += 1;
    }
  }
  fs.writeFileSync("./data/dataset/samples.json", JSON.stringify(samples));
}

buildDataset();

What the output looks like

  1. Per-sample stroke files for model input and augmentation.
  2. Matching PNG images for previews and quick inspection.
  3. A samples manifest mapping each ID to its label and contributor.

Real-world use cases

  • Whiteboards and annotation: strokes as data enable undo, replay, and export.
  • Signature and sketch pads: touch-ready input with compact JSON payloads.
  • ML data collection: labeled strokes plus rendered images from one pipeline.

Best practices for canvas apps

  • Prefer Pointer Events over separate mouse and touch handlers.
  • Store strokes as data and treat rendering as a pure replay function.
  • Scale coordinates by the canvas-to-CSS ratio for crisp output.
  • Clean up listeners when the component unmounts to avoid leaks.

Common mistakes to avoid

  • Assigning onmouseup directly, which overwrites other page handlers.
  • Skipping pointer capture, which drops fast strokes at the edges.
  • Saving only pixels and losing the stroke structure needed for datasets.

Performance note

Clear and redraw the full canvas on each change for sketch sizes. Switch to layered canvases or dirty-region rendering only when stroke counts grow into the thousands.

Frequently asked questions

Why store strokes instead of images?

Strokes are small, resolution independent, and replayable. Images can always be rendered from strokes, but strokes cannot be recovered from a flattened image.

How do touch and pen work without extra code?

Pointer Events normalize mouse, touch, and pen into one event stream, so one set of handlers covers desktop and mobile drawing.

Key takeaways

  • Model drawings as paths of points and render by replaying them.
  • Use Pointer Events with capture for reliable mouse and touch input.
  • Export strokes plus images so the same work powers both UI and datasets.