The Unfolding of a Photo Editor

A sequence in nine steps, each one a working editor, each one a single HTML file — with two branches that had to be cut, preserved, and a tenth step demanded by the first outside trial. After Christopher Alexander; a companion to The Unfolding of a CMS.

Scroll, and it develops

The CMS grew as a client–server organism, and its skeleton was the route table: the map of pathways a request could take, which doubled as a table of contents of the program's own growth. A photo editor is a different animal. Everything happens on the client — the pixels never need a server — and the whole program is one HTML file you can open by double-clicking, mail to a friend, or read in a text editor. There are no routes. So the skeleton must be something else, and it is: a module table, printed in the head of every version, naming the parts of the one file in the order they appear — and, as it happens, roughly the order they were grown. It is the route table's client-side cousin, and it is reprinted below at every step so you can watch it lengthen.

The same three rules govern this sequence as governed the last one. First: after every step the program runs, end to end — every file linked below opens in a browser and works, from the empty frame of step one to the layered document of step nine. Second: each step is chosen so that the most subsequent steps depend on it; the sequence tries to be, in Alexander's phrase, structure-preserving — each transformation deepens what exists rather than rearranging it. Third, and new in this document: the mistakes are kept. Twice, a step was built, worked, and was then cut, because living with it for even one further step showed it damaging the steps to come. Those two branch versions are numbered like the deprecated branches they are (3a, 5a), they still run, and they are attached below behind their fork marks — hidden by default, because the smooth sequence is the primary story, but one click away, because a sequence you have never had to correct is a sequence you have never examined.

One honesty note before the first step. Every version linked here was booted headlessly and driven through its own console — documents made, strokes painted, undo exercised, selections clipped, projects saved and reopened — before this document was written. Where a number is quoted (and one number below is the entire argument of a step), it was measured, not estimated.

I · The Vessel

Step 1 · The Frame

The frame

Name the places before you grow the organs that will live in them.

The first version is a working page that edits nothing. It is the editor's body plan: a top bar for file verbs, a tool rail on the left, a stage in the middle, a panels column on the right, a status bar along the bottom. The buttons are present and honestly disabled — hover one and its tooltip says which step it is waiting for. The tool rail carries only a vertical note reading tools · step 4; the panels column promises layers · step 3. The frame names its future organs, which is not decoration: every later step will land in a place that already exists, so no later step has to rearrange the body to fit.

The module table is born here too, three rows long, in the head of the file — and a small door is left open for the curious: the live modules are hung on window.E, so the browser console can reach in and poke the organism at any step. (That door is also how each version was tested.)

MODULE TABLE - the map of this file
+ js/helpers    tiny dom + file utilities         step 1
+ ui/shell      top bar / tool rail / stage /
                panels / status                   step 1
+ js/wiring     buttons, panels, keyboard         step 1+

Three rows. Watch this table for the rest of the document.

open editor-01-frame.html →

Step 2 · The Round Trip

Open & save

The first pathway is the round trip: a picture in, a picture out.

Before any editing exists, the traffic must. New makes a document (a size and a background); Open and drag-and-drop bring an image in; Export PNG and Export JPEG carry it out as a normal browser download. The editor at this step is, frankly, a file converter — but it is a complete converter, and the two pathways it establishes are the ones every later step will stand on. This is the same instinct that put serving-a-page first in the CMS: user value flows in at the ends, so grow the ends first.

One seam laid here matters more than it looks. The document is drawn to the screen by one function and flattened for export by another, and both are kept deliberately close, because step 3 is about to fuse them — and a branch is about to be cut for failing to.

function exportAs(type){
  if (!Doc.current) return;
  const jpeg = type === 'jpeg';
  const c = flatten(jpeg ? '#ffffff' : null);   // the same drawing,
  c.toBlob(b => downloadBlob(b,                 // aimed at a file
    Doc.current.name + (jpeg ? '.jpg' : '.png')),
    jpeg ? 'image/jpeg' : 'image/png', 0.92);
}

The way out. PNG keeps transparency; JPEG is flattened onto white.

open editor-02-open-save.html →

II · The Substance

Branch 3a · built, worked, cut

Layers as a stack of <canvas> elements

The browser's free lunch turned out to be a second kitchen.

The seductive first answer to "how do layers work" in a browser: make each layer a real <canvas> element, absolutely positioned in a stack, and let the browser composite them. Visibility is display; opacity is opacity; blend modes are mix-blend-mode; reordering is z-index. It works — the file below does all of it — and for a while it feels like getting the compositor for free.

Then comes Export, which cannot screenshot the DOM. So the branch grows a second compositor, written by hand, which must reproduce — pixel for pixel, forever — whatever the CSS one happens to do, including a translation table from CSS blend-mode names to canvas composite-operation names. Two compositors, one truth, is the smell of a trivial decision damaging important ones: every future feature that touches pixels (tools, zoom, selection) would have had to reach through CSS transforms to find a document coordinate, and every future blend mode would have had to be implemented twice and kept in agreement. The branch was cut the moment this was seen clearly; its successor keeps the layers as offscreen canvases and writes one compositor that serves both the screen and the file.

/* the second compositor, and its confession */
const CSS_TO_CANVAS = { normal:'source-over', multiply:'multiply',
  screen:'screen', overlay:'overlay', darken:'darken', lighten:'lighten' };
function flatten(bg){
  ...
  for (const L of d.layers){
    x.globalAlpha = L.opacity;                    // again
    x.globalCompositeOperation =
      CSS_TO_CANVAS[L.blend] || 'source-over';    // and translated
    x.drawImage(L.canvas, 0, 0);                  // and again
  }
}

Everything the browser was doing above, done again by hand, forever.

open editor-03a-dom-layers.html (deprecated) →

Step 3 · The Stack

The layer stack

One truth about what the picture looks like, told once, to everyone.

The single canvas of step 2 differentiates. A document becomes a size, a name, and a list of layers, each an offscreen canvas with a position, a visibility, an opacity and a blend mode. Nothing on screen is a layer anymore; the screen shows the output of one function, composeTo(ctx), which draws the stack into any context — the visible stage through the view transform, or a fresh canvas at document scale for export. The flattened PNG and the pixels on screen cannot disagree, because they are the same drawing.

Around the stack grows its furniture: a layers panel with thumbnails, add / duplicate / delete / raise / lower, an opacity slider, a blend-mode menu, and Import layer to place a second image over the first — at which point this one file is already doing the thing most people actually open a photo editor to do.

/* the one compositor - the whole argument of this step */
function composeTo(ctx){
  for (const L of Doc.current.layers){
    if (!L.visible || L.opacity === 0) continue;
    ctx.globalAlpha = L.opacity;
    ctx.globalCompositeOperation = L.blend;
    ctx.drawImage(L.canvas, L.x, L.y);
  }
  ctx.globalAlpha = 1;
  ctx.globalCompositeOperation = 'source-over';
}

Eleven lines. The screen calls it; the export calls it; they cannot drift.

open editor-03-layers.html →

III · The Hand

Step 4 · The Pointer Pathway

The hand: move, brush, eraser

Every tool speaks document coordinates; one translator stands at the door.

Three pointer events arrive at the stage — down, move, up — and are handed to whichever tool is active, after passing through exactly one function, View.toDoc, which turns screen pixels into document pixels. The tools never see the screen. This is the quietest and most consequential seam in the sequence: in step 6, when zoom and pan arrive and the mapping becomes a real transform, not one line of any tool changes.

The first three tools: V moves the active layer, B paints on it, E erases from it — and the eraser is nothing but a brush whose composite operation is destination-out, so the two share one stroke engine. The tool rail's buttons are simply the letters themselves; the toolbar teaches its own shortcuts. Notice also what the stroke engine already does at every call site: it announces its intentions to a history that does not exist yet (H.begin, t.dirty, H.end — a shim, for now). That doorway is about to matter twice.

function docPoint(e){
  const r = stage.getBoundingClientRect();
  return View.toDoc(e.clientX - r.left, e.clientY - r.top);
}
stage.addEventListener('pointerdown', e => {
  ...
  if (Tools.active) Tools.active.down(docPoint(e), e);
});

The only place screen coordinates exist. Everything past this line is document space.

open editor-04-tools.html →

Branch 5a · built, worked, cut

History by snapshot

It worked on the first try — and the status bar was already writing the bill.

The obvious undo is a time machine: before and after every action, copy everything — every layer's pixels, every property — and undo by restoring a copy. It is twenty minutes of code, it is impossible to get wrong, and the file below ships it, working. It also carries its own indictment: the status bar in this branch displays what history is costing, live. Paint one dot on a single-layer 200×200 test document and the meter reads 320,000 bytes — two full copies of the document for one touch of the brush. Scale that to a 3000×2000 photograph with three layers and each brushstroke costs roughly 144 MB; an idle sketching session is gigabytes. The meter was added out of curiosity and became the reason the branch died.

What was kept from the branch is the most important thing in it: the call sites. Tools talk to history through four verbs — H.begin, t.dirty, H.end, H.act — and those verbs turned out to be right. Step 5 replaces the engine behind them and changes not one caller.

snap(){                       // before AND after EVERY action
  return { ...doc, layers: d.layers.map(l => ({
    ...props, canvas: cloneCanvas(l.canvas)   // every layer, whole
  }))};
}
cost(){ return `history ${...} - ${fmtBytes(this.bytes)} (!)`; }

The whole engine, and the meter that convicted it.

open editor-05a-snapshot-history.html (deprecated) →

Step 5 · Memory

Undo & redo

Make the interface right, then make the engine honest.

Same four verbs, new engine: a command journal. A painting action now records only the dirty rectangle it touched — the pixels before and the pixels after, of that rectangle, on that one layer. Structural actions (add a layer, reorder, rename, change opacity) record a pair of closures. The same dot that cost 320,000 bytes in the branch costs 2,048 bytes here — both numbers measured on the same test — and a slider drag becomes a single history entry rather than sixty. The journal is capped at sixty entries and reports its true cost in the same status-bar meter the branch used, which now reads like an apology accepted.

Because every mutation in steps 3 and 4 was already phrased as begin / touch / end, undo arrives everywhere at once: strokes, erasures, layer moves, reorderings, renames, opacity, blends. Nothing needed to be taught about history; everything had been speaking its language before it existed.

end(t){                              // the honest engine
  const before = pre.getContext('2d')
      .getImageData(x, y, w, h);     // just the dirty rect,
  const after  = L.ctx
      .getImageData(x, y, w, h);     // just this layer
  this.push(t.label,
    () => { L.ctx.putImageData(before, x, y); },
    () => { L.ctx.putImageData(after,  x, y); },
    before.data.length * 2);
}

History pays for what changed, not for what exists.

open editor-05-history.html →

IV · The Eye

Step 6 · The View

Zoom & pan

Because every tool asked politely at the door, moving the door moved no tool.

Until now the view knew one trick: fit the document to the window. Now it becomes a first-class thing — a scale and an offset. The wheel zooms toward the cursor (the point under your pointer stays under your pointer, which is a two-line change of reference frame worth writing carefully); Space-drag pans; 0 fits, 1 is one document pixel per screen pixel. The checkerboard, the marching ants to come, and the brush's outline ring all scale with it.

The step earns its place in the sequence by what it did not require: the brush, the eraser, the move tool and the whole of history were untouched, because step 4 routed every pointer event through View.toDoc. This was the debated step, incidentally — the one that would not sit still in the drafts. It is discussed in the re-reading at the end.

setScale(s, cx, cy){
  const dx = (cx - this.ox) / this.scale,   // doc point under cursor
        dy = (cy - this.oy) / this.scale;
  this.scale = s;
  this.ox = cx - dx * s;                    // pin it back in place
  this.oy = cy - dy * s;
}

Zoom-at-cursor: change frame, pin the point, done.

open editor-06-view.html →

Step 7 · The Boundary Within

The marquee

A selection is not a thing you have; it is a constraint every mutation obeys.

The fourth letter joins the rail: M drags a rectangle in document space, drawn with marching ants (two dashed strokes, offset in counter-phase, marching at eight document-pixels per cycle regardless of zoom). Ctrl+A selects all; Ctrl+D or a bare click deselects; and because selecting is an act, selection changes ride the same history as everything else — undo un-selects.

But the marquee's real content is not the rectangle; it is the discipline. The selection module exposes one clipping verb, and the stroke engine wraps every stroke in it, so brush and eraser simply cannot touch pixels outside. Step 8's filters will inherit the same constraint through the same verb without asking. (The rectangle is deliberately the simplest selection that forces the discipline to exist; the lasso is a differentiation of this module, not a new idea — see the unfoldings not taken.)

clip(ctx, L){                    // the one clipping verb
  if (!this.rect) return;
  ctx.beginPath();
  ctx.rect(this.rect.x - L.x, this.rect.y - L.y,
           this.rect.w, this.rect.h);
  ctx.clip();                    // now the layer can only be
}                                // touched inside the selection

Tools do not check the selection; they are simply unable to disobey it.

open editor-07-selection.html →

V · The Chemistry, and the Return

Step 8 · The Chemistry

Adjustments

A filter is a brushstroke made of arithmetic, riding the same history.

Brightness, contrast and saturation sliders with an Apply button; one-click grayscale, invert, and a small box blur. The interesting thing is how little machinery they needed: one function, applyPixels, asks the selection for its intersection with the active layer, tells history it is about to touch that rectangle, runs the arithmetic over the ImageData, and ends the entry. Undo, redo, selection-respect and the history meter all work on the first run, because filters are consumers of seams laid in steps 5 and 7, not authors of new ones. When a feature lands this quietly, it is the system's way of saying the earlier order was right.

function applyPixels(label, fn){
  const L = paintable(); if (!L) return;
  const r = Sel.layerRect(L);          // step 7's gift
  const t = H.begin(label, {layer: L}); // step 5's gift
  const img = L.ctx.getImageData(r.x, r.y, r.w, r.h);
  fn(img);                              // the only new part
  L.ctx.putImageData(img, r.x, r.y);
  t.dirty(r.x, r.y, 0); t.dirty(r.x + r.w, r.y + r.h, 0);
  H.end(t);
}

Every filter is this function plus arithmetic.

open editor-08-filters.html →

Step 9 · The Return

The document that returns

The document itself becomes a file: step 2, an octave higher.

Export flattens; that was the point of export. But the layered document — the stack, the names, the offsets, the blend modes — deserved to survive a browser tab closing. Save project writes it as a single JSON file, each layer's pixels traveling as a PNG data-URL, and Open now accepts that file back and rebuilds the stack, properties and all. The sequence ends where it began, one level up: the round trip of step 2, carrying not a picture but the work.

And the JSON is not only a save format. The day this editor grows a server — an App Engine service, say, with projects in Cloud Storage and their metadata in Datastore — this file is the wire format, and the seam it crosses is one function on each side. The server, which was deliberately left out of this sequence, attaches at this step's shoulder without disturbing a single earlier one. That is what it feels like when a sequence ends at a boundary instead of merely stopping.

{ "app": "emulsion", "format": 1,
  "name": "sunset", "w": 3000, "h": 2000,
  "layers": [
    { "name": "Background", "x": 0, "y": 0,
      "visible": true, "opacity": 1,
      "blend": "source-over",
      "png": "data:image/png;base64,..." },
    ...
  ]}

The project on disk today; the request body, someday.

open editor-09-project.html →

VI · The First Outside Trial

Step 10 · The Correction

The first stranger's hand

A sequence is not finished until a hand that did not grow it has touched it — the trial is a transformation too.

The report from the first beta tester, verbatim in substance: the brush and eraser paint a few hundred pixels below and to the right of the cursor. Everything else worked. The offset is the interesting part — not constant, but growing toward the bottom-right — and it convicts a whole class of suspect at once: this is the signature of the canvas displaying at a different scale than the pointer arithmetic assumes.

So the hunt ran backwards down the sequence. Step 4, the translator at the door — docPoint, View.toDoc — was read first and acquitted; the arithmetic is right. Step 6, the view transform: acquitted; zoom-at-cursor pins its point correctly. The trail ended at editor-01, in the stylesheet, in one declaration laid before any pixel existed: #stage{position:absolute; inset:0}. A <canvas> is a replaced element, and inset:0 does not stretch a replaced element — its displayed size stays its intrinsic size, which is the backing store, which step 2 sizes at clientWidth × devicePixelRatio. At a display scaling of exactly 1:1 the two sizes coincide and the flaw is invisible — which is why nine steps and a headless test rig (no layout, dpr of 1) lived over it without a tremor. On a real screen at Windows 125% or 150% scaling, a retina display, or any browser zoom, the canvas is shown at backing size: everything drawn appears multiplied by dpr from the top-left corner, while the pointer math aims, correctly, at the unmultiplied place. The hand misses by (dpr − 1) × the distance from the corner. A few hundred pixels, below and to the right, worse as you work down the page. Exactly the report.

The correction is one line: pin the canvas's CSS size to its box, and let the backing store keep its devicePixelRatio for crispness. It ships as editor-10; steps 01–09 are left exactly as published, because they are the record, and the record now includes this. Two morals, honestly earned. First, the CMS coda's warning returns in new clothing: a trivial decision made early and implicitly ("the stage fills its box" — assumed, never stated) is the kind that damages important decisions later, and it hid in the one step that seemed too simple to be wrong. Second: every version here was tested headlessly before shipping, and this bug is invisible to every such test by construction — there is a class of truth only a real screen, a real pointer, and a hand that did not write the code can reach. The first outside trial is not after the sequence. It is step ten.

/* editor-01, css/base - the flaw, dormant for nine steps */
#stage{position:absolute;inset:0}      /* a canvas is a replaced
                                          element: inset does not
                                          stretch it */

/* editor-10 - the correction */
#stage{position:absolute;inset:0;
       width:100%;height:100%}         /* pinned to its box; the
                                          backing store keeps dpr */

One line, laid in step 1, felt in step 4, found by the first stranger.

open editor-10-correction.html →

Coda

Re-reading the sequence

The confessions, in the order they happened.

The two branches are the record, not an ornament. Both were built whole and working before they were cut — you can open them above and use them. What killed each was not a bug but a forecast: living with the DOM stack for one more step meant maintaining two compositors forever; living with snapshots meant an undo whose price grew with the document instead of with the edit. In both cases the replacement kept the boundary of the dead branch — the layers panel's behavior in one case, the four history verbs in the other — and replaced only the organ behind it. Alexander would say the centers were preserved; a programmer would say the interface survived its first implementation. These are the same sentence.

Zoom would not sit still. In one draft the view came before the tools (you should be able to see closely before you touch); in another, last (it changes no pixels, so it felt cosmetic). Both drafts were answering the wrong question, and the right question was the CMS gate's question again: not where does this rank, but what must exist on each side of it. The view had to come after the pointer pathway (step 4) so there was a single toDoc door for it to move, and before the marquee (step 7) so that selections could be dragged accurately at any magnification. Between those posts there is exactly one gap, and step 6 sits in it.

The generator's gates failed twice, instructively. The nine versions and two branches are cut from one master source by feature gates, so that a fix in any shared line heals every version at once. Twice during the build a gate was written as or where the meaning was and — and both times the casualty was a shim: the placeholder history and the placeholder selection, which briefly moved in alongside the real engines they existed to make room for. The lesson generalizes beyond generators: the seams you cut for a thing's future arrival are exactly the places where its arrival will conflict with the scaffolding, so test the handoff, not just the two ends.

And the unfoldings not taken, which are the proof the structure is alive: the lasso (the selection rect differentiates into a mask canvas; Sel.clip keeps its name and every tool obeys the new shape unknowingly); layer masks (a second canvas per layer, consulted by the one compositor); transforms (the move tool's drag differentiates into handles; the layer's x,y into a matrix); text and shapes (layers whose pixels are grown from a description — a new kind of layer, not a new system); WebGL filters (a faster arithmetic behind applyPixels, which changes no caller, exactly as step 5 replaced step 5a); tiling for very large documents (the layer canvas differentiates into a grid of canvases beneath composeTo); and the server's return, waiting at step 9's shoulder. Each attaches at a seam you can name. When a proposed feature has no such seam, the system is saying it belongs to a different sequence — or that an earlier step is still too trivial.

Read the finished module table one more time. Thirteen rows now, and they stand in nearly the order they arrived: the frame, the traffic, the substance, the memory, the eye, the boundary, the chemistry, the return — and one row amended by the first stranger's hand. The one file reads as the story of its own growth — which was the point, in 2008, in the CMS, and here.

The package

This document travels with twelve working editors — nine steps, two preserved branches, and the correction from the first outside trial. Keep them in one folder and the links above open them; each is a single self-contained HTML file with its module table in its head, readable in any text editor. No build, no server, no network.

the-unfolding-of-a-photo-editor.html   (this document)
emulsion/editor-01-frame.html                 the frame
emulsion/ditor-02-open-save.html           open & save
emulsion/editor-03a-dom-layers.html           branch - cut
emulsion/editor-03-layers.html          the layer stack
emulsion/editor-04-tools.html          move / brush / eraser
emulsion/editor-05a-snapshot-history.html     branch - cut
emulsion/editor-05-history.html              undo & redo
emulsion/editor-06-view.html               zoom & pan
emulsion/editor-07-selection.html           the marquee
emulsion/editor-08-filters.html             adjustments
emulsion/editor-09-project.html            save the layered work
emulsion/editor-10-correction.html             the correction - use this one