Layers in a browser image editor: how non-destructive editing actually works

7 min read
images
canvas
editing

Most online "photo editors" are a filter list with an upload button. The moment you need two pictures on one canvas, a logo in the
corner and text that stays editable, they fall apart. The missing idea is not filters — it is layers.

A layer is a description, not a bitmap

The naive way to place an image on a canvas is to draw it and forget it. Every later change then edits pixels that already lost
information: scale down, scale back up, and the detail is gone for good. A layer model stores a description instead:

type Layer = {
  src: ImageBitmap        // the untouched source
  x: number; y: number    // centre position in image pixels
  w: number; h: number    // drawn size
  rotation: number        // radians
  flipX: boolean; flipY: boolean
  opacity: number
  locked: boolean
}

Nothing in that object is destructive. Rendering is a pure function of the list: clear the canvas, then for each visible layer
translate to its centre, rotate, scale, and draw the original bitmap. Resize a layer twelve times and the twelfth render still
samples the full-resolution source, because only numbers changed in between.

Why coordinates should live in image space, not screen space

The canvas on screen is usually smaller than the picture you are editing. If handles and positions are stored in CSS pixels, every
zoom change silently rescales your work and hit-testing drifts on high-DPI screens. Store everything in image pixels and convert at
the boundary:

const scale = canvas.width / rect.width
const toLocal = (e: PointerEvent) => ({
  x: (e.clientX - rect.left) * scale,
  y: (e.clientY - rect.top) * scale,
})

Now a layer that is 400 px wide is 400 px wide in the exported file, whatever the browser window is doing.

Hit-testing rotated layers

Clicking a rotated layer is the part people get wrong. You do not rotate the rectangle — you rotate the point. Translate the
cursor into the layer's own frame by subtracting the centre and applying the inverse rotation, then compare against an axis-aligned
box:

const dx = px - layer.x, dy = py - layer.y
const c = Math.cos(-layer.rotation), s = Math.sin(-layer.rotation)
const lx = dx * c - dy * s, ly = dx * s + dy * c
const hit = Math.abs(lx) <= layer.w / 2 && Math.abs(ly) <= layer.h / 2

The same transform gives you the eight resize handles and the rotation stem: compute them in local space, map them out for drawing,
map the cursor in for testing. One helper, no special cases.

Handles that behave the way people expect

  • Corner handles scale both axes; hold the aspect lock and the shorter axis follows the longer one.
  • Edge handles stretch a single axis — the honest way to fit a background to a canvas.
  • The stem above the layer rotates, snapping to 15° increments while Shift is held, because almost every rotation people want is a

multiple of 15.

  • Arrow keys nudge by one pixel and by ten with Shift, which is faster than any mouse for final alignment.

Scaling from the centre rather than the opposite corner is the one deliberate deviation from desktop editors: on a touch screen it
keeps the layer under your finger instead of walking it off the canvas.

Exporting

Because rendering is a pure function of the layer list, export is the same function pointed at an offscreen canvas at full
resolution. There is no separate "flatten" code path to keep in sync, and no quality loss from the preview size.

None of it needs a server

ImageBitmap, OffscreenCanvas and canvas.toBlob() are all standard browser APIs. Decoding, compositing and encoding happen in
your tab, so your pictures are never uploaded, never queued, and never stored. That is why the editor keeps working when you go
offline after the page has loaded.

Open the Image Editor and drop in two pictures: drag to move, pull the corner dots to resize, use the
dot above the layer to rotate, and type exact width, height and position when you need precision.

Tools from this article

Comments

No login needed. Comments appear after a quick review.

Protected by an on-site captcha — no third-party trackers.

Loading comments…

← All articles