DEV Community

김기선
김기선

Posted on Originally published at slohero.com

Building reversible layer masks in a browser image editor

The feature I wanted was a reversible layer mask: hide part of an image without destroying its source, disable the mask to recover the image, and retain the same document state when it is serialized and composited again. This is a concise engineering account of the implementation in Photoshot, a browser image editor.

1. Start with the document contract

A layer needs its own position, dimensions, rotation, opacity and blend mode. A mask belongs to the editable document, separately from its source image or paint strokes. A rendered display object is a temporary interpretation of that document.

The first public Stage used React and PixiJS. Its adapter created a Sprite and transferred the document fields into renderer properties:

      const s = new Sprite(Texture.from(canvas));
      s.anchor.set(0.5);
      s.position.set(l.x + l.width / 2, l.y + l.height / 2);
      s.rotation = (l.rotation * Math.PI) / 180;
      s.alpha = l.opacity;
      s.blendMode = l.blend;
      a.stage.addChild(s);
Enter fullscreen mode Exit fullscreen mode

The half-width/height offset follows the centered anchor. Rotation is converted from degrees to radians. These are application-specific integration decisions; installing PixiJS does not supply them.

Document state, temporary surface and shared compositor

Source-level architecture; arrows show data flow, not timing.

2. Where the implementation became difficult

A Sprite does not define our project schema, mask history or export semantics. The early preview used PixiJS while export had a separate Canvas composition loop. That split created two places that needed to interpret the same document correctly.

Source inspection establishes the separate paths. It does not establish an archived preview/export mismatch for every image. The problem addressed here is the responsibility boundary between persistent state and rendering.

3. What came from GitHub—and what was written locally

React supplied UI infrastructure. PixiJS supplied the early rendering APIs. The document model, coordinate adapter, mask plumbing and later common compositor were Photoshot code. This was library reuse plus application integration, rather than a transplanted third-party editor.

Photopea's mask documentation was a behavioral reference: disabling a mask should recover content. It was not a source-code transplant. The concrete implementation references are the initial Photoshot commit eead3ca and the later 19f45ed (v0.16.0).

4. Preserve the source; mask a temporary surface

layerSurface draws the retained source into a fresh Canvas and applies the mask there:

  if (layer.mask?.enabled) {
    ctx.globalCompositeOperation = 'destination-in';
    ctx.drawImage(maskSurface(layer.mask), 0, 0, c.width, c.height);
    ctx.globalCompositeOperation = 'source-over';
  }
Enter fullscreen mode Exit fullscreen mode

destination-in retains destination pixels according to mask alpha. The code then restores source-over. Source strokes and mask strokes remain separate document fields, so disabling the mask regenerates the surface from the source.

The later Stage also calls the shared composite function for preview. That consolidates layer order, transforms, blending and adjustments. Preview still bounds the longest edge to 1,600 pixels, so a preview and a full-resolution export need a resolution-matched comparison before claiming pixel equivalence.

Source, enabled mask, destructive control and recovered document mask

New diagnostic outputs. The destructive control is not a historical product screenshot.

5. A controlled failure and a successful recovery

For this article, the unchanged document engine was run with Node and native Canvas on a 100 × 100 pixel paint layer. A 40-pixel mask stroke hid the center. Five deterministic checks passed:

Check Observation
Source document before/after rendering Identical JSON
Enabled mask at the center Alpha 0
Restoring a smaller mask stroke Center alpha 255
Disabling the document mask Entire RGBA array equals the source
JSON serialize → validate → composite Equals the masked render

The negative control erased pixels directly from a retained Canvas. Stopping that erase operation left the center transparent, while disabling the document mask restored it to alpha 255. This control was constructed for the diagnostic; it is not a recovered screenshot of an old Photoshot failure.

The result supports source preservation for this input and revision. It is not a browser interaction test, an IndexedDB durability test, or a claim that every blend mode and format matches Photoshop.

Sources and the complete research note

For the full version, including architecture diagrams, rendered failure/recovery panels, the alpha profile and reproducibility files, read the complete research note on my website.

Writing and source review were assisted by AI. This account is grounded in the cited project revisions and recorded diagnostic results.

Top comments (0)