Building an Image Editor with Canvas API

July 6, 2018 - 6 minute read -
web angular hammerjs

Zoom Pan

Problem

I needed to build an image editor on the browser. Here are the requirements:

  1. User can zoom and pan the image
  2. User can draw on the image
  3. Image editor needs to a fixed size.
  4. Edited image needs to be in original resolution.
  5. Desktop and Mobile friendly.

If it weren’t for requirement number 4, this image editor would have been very easy to make. Just make our image editor the size of the source image. Then any edited images will still be in the original resolution. However, if you have a high resolution image and your canvas viewport is smaller than the image dimensions, the canvas will downsample the image.

However, with a bit of math we can preserve resolution. Let’s use two canvases. One canvas is the editor. The other canvas holds the source image. The editor canvas is a fixed size. However, the source canvas is exactly the size of the image so there is no resolution change. Whenver the user draw on a point $P_{editor}$, just mirror the drawing action back to $P_{source}$! When the user is done drawing, we can export the source canvas instead of the editor to give them an edited image in full resolution!

We will be using Hammer.js. HammerJS lets us add in pinch to zoom and unifies mouse and finger panning detection. If you’re building a desktop only app, you can just use native mouse events instead.

I’ve provided an Angular 5 Demo but this can be easily done in Vanilla JS as well.

Displaying the image

HTML Canvas are containers for browser graphics. You can interact with them through javascript.

<canvas id="canvas"></canvas>
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');

ctx.fillStyle = 'green';
ctx.fillRect(10, 10, 100, 100);

So to load an image into the canvas, we will call the drawImage method on our canvas context.

void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

This is just matrix multiplication.

This function lets us take a portion of the source image and project it onto our canvas. Remember how we need to zoom and pan? This powerful method will allow us to do both. Our first step is to make the canvas a “window” of our source image.

dx = 0
dy = 0
dWidth = canvasWidth
dHeight = canvasHeight

Now given an arbitrary image portion, it will be stretched and fitted so that our entire canvas displays the portion.

Zooming

For zooming, we just have to alter the dimensions of the source image. For example, to have a 2x zoom, we just need to divide our sWidth and sHeight by 2. Then the canvas will have to stretch out the shrunken image portion over its dimensions, effectively zooming it in. Conversely, if we want to zoom out 2x, we can multiply sWidth and sHeight by 2. Now the canvas has to squish in an expanded image portion, making it appear smaller.

// zoom in by 2x
const sWidth = this._image.width / 2;
const sHeight = this._image.height / 2;
this._canvasContext.drawImage(image, sx, sy, sWidth, sHeight, 0, 0, this._canvas.width, this._canvas.height);

Panning

For panning, we just have to alter the offsets of the source image. The user clicks the image, then drags their mouse to move the image around. On the initial click, we save the location of their mouse. Then while their mouse is moving, we calculate the offset between the initial location and the current location. This offset represents the amount to move the image by to keep the image centered around the initial location.

One important point to remember is that the user is interacting with the canvas, not the source image. So we must translate canvas coordinates to source image coordinates whenever we want to edit the source image portion.

\[X_{c} = (X_{s} - S_{x}) * \frac{W_c}{W_s} \\ X_{s} = \frac{X_c}{\frac{W_c}{W_s}} + S_{x}\]

Let

  • $X_{c}$ be X coordinate of canvas
  • $X_{s}$ be X coordinate of source image
  • $S_{x}$ be X offset of source image
  • $W_{c}$ be width of canvas
  • $W_{s}$ be width of source image
onPanStart(event) {
    const sWidth = this._image.width * this._canvasZoom;
    const sHeight = this._image.height * this._canvasZoom;
    this._canvasPanStart.x = event.center.x / (this._canvas.width / sWidth) + this._sx;
    this._canvasPanStart.y = event.center.y / (this._canvas.height / sHeight) + this._sy;
}
onPanMove(event) {
    const sWidth = this._image.width * this._canvasZoom;
    const sHeight = this._image.height * this._canvasZoom;
    const x = event.center.x / (this._canvas.width / sWidth) + this._sx;
    const y = event.center.y / (this._canvas.height / sHeight) + this._sy;
    this._sx += (this._canvasPanStart.x - x);
    this._sy += (this._canvasPanStart.y - y) ;
    this._canvasContext.drawImage(this._image, this._sx, this._sy, sWidth, sHeight, 0, 0, this._canvas.width, this._canvas.height);
}

Zoom Pan

Drawing

Now that we figured out how to map coordinates between source and canvas, drawing is easy! Whenver the user draw on a point $P_c$, just mirror the drawing action back to $P_s$! If you don’t want to show the source canvas to the user, create it dynamically in javascript instead of declaring it in HTML.

You can check out the demo for exact details.

Drawing

Limitations

  1. Panning is only allowed if viewport is within source image dimensions.
  2. Panning and zooming will erase drawings in editor canvas (but not source!)

If $S_x$ and $S_y$ become negative, then the behavior of drawImage becomes undefined on iOS. Therefore I had to disable panning unless the editor is within the dimensions of the image. If you’re desktop only, disable my realignImage function and you can pan however much you want.

I didn’t have time to implement storage of user edits. There is a save and restore function in the Canvas API which could be promising.