Frontend Development 30 min read

Step-by-Step Guide to Building a Canvas-Based Image Annotation Tool with Zoom, Pan, Edit, and Rotate

This tutorial walks you through creating a full‑featured image annotation tool using HTML5 Canvas, covering image rendering, mouse‑wheel zoom, viewport panning, rectangle drawing, selection, drag‑move, size adjustment, and rotation with detailed JavaScript code examples.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Step-by-Step Guide to Building a Canvas-Based Image Annotation Tool with Zoom, Pan, Edit, and Rotate

The article introduces a practical solution for implementing an image annotation feature where reviewers can mark up images, zoom, pan, and edit annotations. It compares two implementation approaches—Canvas and DOM+SVG—highlighting the performance advantages of Canvas for complex graphics.

Canvas Rendering

First, a Canvas element of size 1000×700 is created, and an image is drawn after it loads. The basic code is:

<body>
  <div>
    <canvas id="canvas1" width="1000" height="700"></canvas>
  </div>
  <script>
    const canvas1 = document.querySelector('#canvas1');
    const ctx1 = canvas1.getContext('2d');
    let width = 1000;
    let height = 700;
    let img = new Image();
    img.src = './bg.png';
    img.onload = function () { draw(); };
    function draw() {
      console.log('draw');
      ctx1.drawImage(img, 0, 0, width, height);
    }
  </script>
</body>

After confirming the image renders, the tutorial adds zoom functionality by listening to the wheel event, using event.deltaY to detect scroll direction, and adjusting a global scale variable between 1 and 3.

document.addEventListener('wheel', function(event) {
  if (event.ctrlKey) {
    event.preventDefault();
    if (event.deltaY < 0) {
      if (scale < 3) {
        scale = Math.min(scale + 0.1, 3);
        draw();
      }
    } else {
      if (scale > 1) {
        scale = Math.max(scale - 0.1, 1);
        draw();
      }
    }
  }
}, { passive: false });

The drawing routine is updated to apply ctx1.scale(scale, scale) and clear the previous frame with ctx1.clearRect . Save and restore are used to keep the transformation local.

let scale = 1;
function draw() {
  console.log('draw');
  ctx1.clearRect(0, 0, width, height);
  ctx1.save();
  ctx1.scale(scale, scale);
  ctx1.drawImage(img, 0, 0, width, height);
  ctx1.restore();
}

For mouse‑centered zoom, the code records the mouse position ( scaleX , scaleY ) before scaling, translates the canvas to that point, scales, then translates back.

let scaleX = 0;
let scaleY = 0;
function draw() {
  ctx1.clearRect(0, 0, width, height);
  ctx1.save();
  ctx1.translate(scaleX, scaleY);
  ctx1.scale(scale, scale);
  ctx1.translate(-scaleX, -scaleY);
  ctx1.drawImage(img, 0, 0, width, height);
  ctx1.restore();
}

document.addEventListener('wheel', function(event) {
  if (event.ctrlKey) {
    if (event.deltaY < 0) {
      if (scale < 3) {
        scaleX = event.offsetX;
        scaleY = event.offsetY;
        scale = Math.min(scale + 0.1, 3);
        draw();
      }
    }
    // omitted code for zoom out
  }
}, { passive: false });

Viewport Panning

When the image is zoomed, only a portion is visible. Panning is achieved by listening to wheel events without the Ctrl key and adjusting translateX and translateY before drawing.

let translateX = 0;
let translateY = 0;
function draw() {
  // ... previous code ...
  ctx1.translate(translateX, translateY);
  ctx1.drawImage(img, 0, 0, width, height);
  ctx1.restore();
}

document.addEventListener('wheel', function(event) {
  if (!event.ctrlKey) {
    event.preventDefault();
    translateX -= event.deltaX;
    translateY -= event.deltaY;
    draw();
  }
}, { passive: false });

Drawing Annotations

Rectangular annotations are stored in an array rects with properties for position, size, rotation angle, and edit state. The draw loop renders each rectangle, applying rotation via ctx1.translate and ctx1.rotate when rotatable is true.

let rects = [{
  x: 650,
  y: 350,
  width: 100,
  height: 100,
  isEditing: false,
  rotatable: true,
  rotateAngle: 30
}];

function draw() {
  // draw image and existing code ...
  rects.forEach(r => {
    ctx1.strokeStyle = r.isEditing ? 'rgba(255, 0, 0, 0.5)' : 'rgba(255, 0, 0)';
    ctx1.save();
    if (r.rotatable) {
      ctx1.translate(r.x + r.width / 2, r.y + r.height / 2);
      ctx1.rotate((r.rotateAngle * Math.PI) / 180);
      ctx1.translate(-(r.x + r.width / 2), -(r.y + r.height / 2));
    }
    ctx1.strokeRect(r.x, r.y, r.width, r.height);
    ctx1.restore();
  });
}

Adding Annotations

Mouse events mousedown , mousemove , and mouseup are used to create a new rectangle while respecting the current zoom and pan offsets. The helper functions computexy , computewh , and computeRect convert viewport coordinates to canvas coordinates.

let drawingRect = null;
let startX = 0;
let startY = 0;
canvas1.addEventListener('mousedown', e => {
  startX = e.offsetX;
  startY = e.offsetY;
  drawingRect = {};
});
canvas1.addEventListener('mousemove', e => {
  if (drawingRect) {
    drawingRect = computeRect({
      x: startX,
      y: startY,
      width: e.offsetX - startX,
      height: e.offsetY - startY
    });
    draw();
    return;
  }
});
canvas1.addEventListener('mouseup', e => {
  if (drawingRect) {
    drawingRect = null;
    const width = Math.abs(e.offsetX - startX);
    const height = Math.abs(e.offsetY - startY);
    if (width > 1 || height > 1) {
      const newrect = computeRect({
        x: Math.min(startX, e.offsetX),
        y: Math.min(startY, e.offsetY),
        width,
        height
      });
      rects.push(newrect);
      draw();
    }
    return;
  }
});

function computexy(x, y) {
  return {
    x: (x - scaleX * (1 - scale) - translateX * scale) / scale,
    y: (y - scaleY * (1 - scale) - translateY * scale) / scale
  };
}
function computewh(width, height) {
  return { width: width / scale, height: height / scale };
}
function computeRect(rect) {
  return { ...computexy(rect.x, rect.y), ...computewh(rect.width, rect.height) };
}

Selecting and Editing

Clicking on an existing rectangle toggles its isEditing flag. The helper poInRect checks whether a point lies inside a rectangle, and poInRotRect extends this check for rotated rectangles by applying an inverse rotation.

function poInRect({ x, y }, rect) {
  return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}

function rotatePoint(point, rotateCenter, rotateAngle) {
  let dx = point.x - rotateCenter.x;
  let dy = point.y - rotateCenter.y;
  let rotatedX = dx * Math.cos((-rotateAngle * Math.PI) / 180) - dy * Math.sin((-rotateAngle * Math.PI) / 180) + rotateCenter.x;
  let rotatedY = dy * Math.cos((-rotateAngle * Math.PI) / 180) + dx * Math.sin((-rotateAngle * Math.PI) / 180) + rotateCenter.y;
  return { x: rotatedX, y: rotatedY };
}

function poInRotRect(point, rect, rotateCenter = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, rotateAngle = rect.rotateAngle) {
  if (rotateAngle) {
    const rotatedPoint = rotatePoint(point, rotateCenter, rotateAngle);
    return poInRect(rotatedPoint, rect);
  }
  return poInRect(point, rect);
}

canvas1.addEventListener('mousedown', e => {
  const { x, y } = computexy(e.offsetX, e.offsetY);
  const pickRect = rects.find(r => poInRotRect({ x, y }, r));
  if (pickRect) {
    if (editRect && pickRect !== editRect) {
      editRect.isEditing = false;
    }
    pickRect.isEditing = true;
    editRect = pickRect;
    draw();
  } else {
    if (editRect) {
      editRect.isEditing = false;
      editRect = null;
      draw();
    }
    drawingRect = {};
  }
});

Moving Annotations

When a rectangle is selected, dragging updates its x and y based on the mouse delta, taking the current zoom into account.

let draggingRect = null;
let shiftX = 0;
let shiftY = 0;
canvas1.addEventListener('mousedown', e => {
  const { x, y } = computexy(e.offsetX, e.offsetY);
  const pickRect = rects.find(r => poInRotRect({ x, y }, r));
  if (pickRect) {
    shiftX = x - pickRect.x;
    shiftY = y - pickRect.y;
    draggingRect = pickRect;
    draw();
  }
});
canvas1.addEventListener('mousemove', e => {
  if (draggingRect) {
    const { x, y } = computexy(e.offsetX, e.offsetY);
    draggingRect.x = x - shiftX;
    draggingRect.y = y - shiftY;
    draw();
    return;
  }
});
canvas1.addEventListener('mouseup', e => {
  if (draggingRect) {
    draggingRect = null;
    return;
  }
});

Resizing Annotations

Eight edit handles (corners, sides, and a rotation handle) are computed in computeEditRect . Clicking a handle sets startEditRect , dragingEditor , and offset values, and mouse movement updates the rectangle dimensions accordingly.

function computeEditRect(rect) {
  let width = 10;
  let linelen = 16;
  return {
    t: { type: 't', x: rect.x + rect.width / 2 - width / 2, y: rect.y - width / 2, width, height: width },
    // other handles (b, l, r, tl, tr, bl, br) omitted for brevity
    ...(rect.rotatable ? {
      rotr: { type: 'rotr', x: rect.x + rect.width / 2 - width / 2, y: rect.y - width / 2 - linelen - width, width, height: width },
      rotl: { type: 'rotl', x1: rect.x + rect.width / 2, y1: rect.y - linelen - width / 2, x2: rect.x + rect.width / 2, y2: rect.y - width / 2 }
    } : null)
  };
}

function drawEditor(rect) {
  ctx1.save();
  const editor = computeEditRect(rect);
  ctx1.fillStyle = 'rgba(255, 150, 150)';
  const { rotl, rotr, ...handles } = editor;
  if (rect.rotatable) {
    ctx1.fillRect(rotr.x, rotr.y, rotr.width, rotr.height);
    ctx1.beginPath();
    ctx1.moveTo(rotl.x1, rotl.y1);
    ctx1.lineTo(rotl.x2, rotl.y2);
    ctx1.stroke();
  }
  for (const h of Object.values(handles)) {
    ctx1.fillRect(h.x, h.y, h.width, h.height);
  }
  ctx1.restore();
}

Rotation

When the rotation handle ( rotr ) is dragged, the angle is updated based on the relative angle between the previous and current mouse positions around the rectangle’s center. The helper getRelativeRotationAngle computes this delta.

function getRelativeRotationAngle(point, prev, center) {
  let prevAngle = Math.atan2(prev.y - center.y, prev.x - center.x);
  let curAngle = Math.atan2(point.y - center.y, point.x - center.x);
  return curAngle - prevAngle;
}

let rotatingRect = null;
let prevX = 0;
let prevY = 0;
canvas1.addEventListener('mousedown', e => {
  const { x, y } = computexy(e.offsetX, e.offsetY);
  if (editRect) {
    const editor = poInEditor({ x, y }, editRect);
    if (editor && editor.type === 'rotr') {
      rotatingRect = editRect;
      prevX = e.offsetX;
      prevY = e.offsetY;
      return;
    }
  }
});
canvas1.addEventListener('mousemove', e => {
  if (rotatingRect) {
    const relativeAngle = getRelativeRotationAngle(
      computexy(e.offsetX, e.offsetY),
      computexy(prevX, prevY),
      { x: rotatingRect.x + rotatingRect.width / 2, y: rotatingRect.y + rotatingRect.height / 2 }
    );
    rotatingRect.rotateAngle += (relativeAngle * 180) / Math.PI;
    prevX = e.offsetX;
    prevY = e.offsetY;
    draw();
    return;
  }
});

The article concludes by summarizing the complete feature set—rendering, zoom, pan, annotation creation, selection, moving, resizing, and rotation—and suggests turning the implementation into a reusable library or a full‑fledged product with additional shape types, export capabilities, and UI enhancements.

frontendJavaScriptcanvasRotationDrag and DropZoomImage Annotation
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login 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.