DEV Community

tommy
tommy

Posted on

I Added 5 Drawing Tools to My Canvas App. Undo Almost Killed It.

The best part of building your own tools is that you can add whatever you want.

I was filing a bug report and opened my own annotation tool to mark up a screenshot. I drew an arrow. But I wanted a freehand pen to circle the area. No pen. I tried a rectangle — the thing I was highlighting wasn't rectangular. I wanted an ellipse. No ellipse either.

I built this thing. And it was frustrating me.

——So I added them. Today.


This is the story of a major update to PureMark Annotate — a browser-based screenshot annotation tool (no install, no login). I started with 5 tools: arrow, text, rectangle, numbered circle, and mosaic. I added freehand pen, marker/highlight, straight line, ellipse, and Undo/Redo all at once.

PureMark Annotate — Works in your browser. No install, no account.
👉 annotate.puremark.app

"Just adding tools to Canvas" sounded simple. There were 3 real gotchas. And they were fun.


The Pen Was Jagged

The first implementation was straightforward. Collect coordinates in a points array on every mousemove, connect them with lineTo.

ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
  ctx.lineTo(pts[i].x, pts[i].y);
}
ctx.stroke();
Enter fullscreen mode Exit fullscreen mode

Slow strokes looked fine. Fast strokes looked like a bar chart.

mousemove doesn't fire continuously — it fires at intervals. Move fast and the gap between events gets large. You connect those distant points with a single straight line. That's your jagged pen.

Fix: Quadratic Bézier curves through midpoints

Use the midpoint between adjacent points as the curve's endpoint, and the previous point as the control point. This interpolates a smooth curve between the raw coordinates.

ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);

for (let i = 1; i < pts.length - 1; i++) {
  const mid = {
    x: (pts[i].x + pts[i + 1].x) / 2,
    y: (pts[i].y + pts[i + 1].y) / 2,
  };
  ctx.quadraticCurveTo(pts[i].x, pts[i].y, mid.x, mid.y);
}
const last = pts[pts.length - 1];
ctx.lineTo(last.x, last.y);
ctx.stroke();
Enter fullscreen mode Exit fullscreen mode

The moment I tested it, the line went smooth. I literally said "oh nice" out loud. A few lines of change, and fast strokes turned into proper curves. These little discoveries are what make indie dev fun.


The Marker Got Darker When Overlapped

The highlight/marker tool should've been simple: globalAlpha = 0.35, then fillRect. Done.

But when I put an arrow on top of a highlighted area, the alpha values composed and the highlight got darker. Two overlapping highlights did the same. Something felt off.

Fix: 3-pass rendering

Every frame I clear the Canvas and redraw all annotations from scratch. I locked in this drawing order:

  1. Pass 1: Mosaics — must read from the original image first
  2. Pass 2: Highlights — rendered below everything else
  3. Pass 3: Everything else — arrows, text, rectangles, pen strokes
for (const a of all) {
  if (a.type === 'Mosaic') drawMosaic(ctx, a, imageCanvas);
}
for (const a of all) {
  if (a.type === 'Highlight') drawHighlight(ctx, a);
}
for (const a of all) {
  if (a.type !== 'Mosaic' && a.type !== 'Highlight') drawAnnotation(ctx, a);
}
Enter fullscreen mode Exit fullscreen mode

Now highlights are always drawn exactly once per frame, so the opacity stays consistent. As a bonus, this "clear and redraw everything" approach turned out to be perfect for Undo/Redo too.


Undo Almost Killed the Tab

This one was the most interesting.

My first instinct: use ctx.getImageData() to snapshot the whole Canvas on every stroke, and push it onto a history array. Simple, intuitive. I built it and ran it.

After about 5 strokes, the Chrome tab silently died. No error. Just gone.

The math made it obvious:

Item Value
Typical iPhone photo 4000 × 3000 px
Data per pixel 4 bytes (RGBA)
One snapshot 4000 × 3000 × 4 = ~46 MB
50 steps of history 46 MB × 50 = ~2.3 GB

Of course it crashed.


I sat with it for a bit. Then it clicked: I'm trying to save what the Canvas looks like. But what I actually need to save is what's drawn on it.

The pixel data is just a rendering output. The real data is the annotations themselves — coordinates, color, type, stroke width. Pure JavaScript objects, a few dozen bytes each.

Fix: history + future stacks of annotation arrays

const MAX_HISTORY = 50;

// on finalize (stroke complete)
set({
  history: [...history.slice(-(MAX_HISTORY - 1)), annotations],
  future: [],
  annotations: [...annotations, currentAnnotation],
});

// undo
const prev = history[history.length - 1];
set({
  future: [...future, annotations],
  history: history.slice(0, -1),
  annotations: prev,
});

// redo
const next = future[future.length - 1];
set({
  history: [...history, annotations],
  future: future.slice(0, -1),
  annotations: next,
});
Enter fullscreen mode Exit fullscreen mode

50 steps of history. Memory cost: a few KB. Ctrl+Z goes back one step. Ctrl+Y goes forward. The obvious thing works obviously.

When it clicked into place, I made a noise.

When getImageData snapshots make sense:
If you need to undo pixel-level changes (like painting or blurring), you do need imageData. But for annotation tools where you're only adding and removing objects, storing the data array is all you need.


Before / After

Before After
Drawing tools arrow, text, rect, counter, mosaic + pen, marker, line, ellipse
Undo none 50 steps
Memory for history a few KB

PureMark Annotate — Open it, paste a screenshot, annotate. Nothing to install.
👉 annotate.puremark.app


Now I can take a bug report screenshot, circle the problem with a pen, and hit Ctrl+Z if I mess up.

The thing I wanted to exist, exists. I made it. That moment — where the tool you built does exactly what you needed — is probably my favorite part of building things for yourself.


References

Top comments (0)