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);
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.
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';
}
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.
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
- Initial Stage and PixiJS adapter
- Later document engine
- React, PixiJS, and Photopea's mask behavior guide
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)