Skip to content

Per-glyph layers with Figma-style move tool - #62

Merged
aurpelai merged 11 commits into
mainfrom
feat/layer-system
Jun 16, 2026
Merged

aurpelai merged 11 commits into
mainfrom
feat/layer-system

Conversation

@aurpelai

@aurpelai aurpelai commented Jun 16, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds a per-glyph layer system (max 5 layers) so users can reposition compound parts like umlauts or strikes without redrawing the base letter. Each layer has its own pixel buffer, offset, ink tint, visibility, lock, and preview-vs-white toggle. The move tool now hit-tests the topmost inked visible layer under the cursor (Figma-style), with Alt+click to cycle through stacked layers and a layer-panel multi-select for whole-glyph drags. The export pipeline is unaffected externally — layers are flattened to one bitmap before atlas packing.

This is Stage A + Stage B in a single PR per the layer-system plan. A small Stage B follow-up is queued to drop the legacy top-level pixel fields from the in-memory `Glyph` type (see "Follow-ups" below).

Highlights

  • Data model. New `Layer` interface with id/name/pixels/width/height/xoffset/yoffset/visible/preview/color/locked. `Glyph.layers: Layer[]` is the new authoritative source; legacy top-level pixel fields are kept on the type and on disk during this PR so first-time rollback to v1 stays safe.
  • Dexie v1→v2 migration. Wraps each existing glyph's bitmap into `layers[0]` via `makeBaseLayerFromBitmap`. Legacy fields preserved.
  • Layer helpers in `src/core/project/layers.ts` — pure functions for flatten, hit-test, cycle-hit, union bounds, add/remove/reorder/update layer mutators, trim-to-ink, sync-legacy-fields. All covered by unit tests.
  • Layer-aware editor. PixelEditor renders each visible layer bottom-up (in its tint or white based on the preview toggle), pencil/eraser target the active layer, move tool hit-tests via `hitTestLayer` and shows the grab outline around just the picked layer. Alt+click cycles, multi-select moves several layers together as one undo step.
  • LayerPanel UI. Right-docked side rail with eye/preview/lock/delete per row, `+` button capped at 5 layers, Shift/Cmd-click for multi-select. Heading and color match the GlyphList style.
  • Trim-to-ink runs on every paint and on project load so layer bounds always hug their visible ink (repairs the misaligned grab outline reported during testing).
  • Palette. Index 0 is white (base layer renders identically to the final exported ink); indices 1–4 are amber/cyan/lime/violet so additional layers stand out.

Test plan

  • Open an existing v1 project — migration runs once, every glyph has `layers[0]` and renders identically to before.
  • Import a TTF — every glyph has one "Base" layer; editor behaviour matches the pre-layer baseline.
  • On a glyph like `ä`: add a second layer, draw two dots, toggle preview off → confirm the dots render in white (final look).
  • Hide the umlaut layer → only the base `a` renders. Re-show it.
  • Move tool: hover the dots → grab outline wraps just the umlaut. Drag — only the dots move; the base stays put.
  • Alt+click on stacked layers to cycle the grab target.
  • Shift-click two layer rows in the panel → drag in the canvas moves both layers together as one undo step.
  • Add 4 more layers, confirm `+` button disables at 5.
  • Lock a layer, confirm pencil and move both no-op on it.
  • Export atlas + .fnt, confirm the flattened result is byte-identical to a single-layer baseline.
  • Undo/redo across paint + move + layer add/remove.
  • Reload the page — every layer's pixels, visibility, preview, color, name, lock state survives the IndexedDB round-trip.

Follow-ups (not in this PR)

  1. Drop the legacy `Glyph` fields. `pixels`/`width`/`height`/`xoffset`/`yoffset` are still optional on the type. Audited as ~8 files to convert (PreviewFloat, GlyphThumbnail, GlyphList forwarders, PixelEditor layout, ImportWizard, EditorScreen, glyphs.ts, portable.ts) plus a v2→v3 strip migration. Worth doing as a separate PR after this bakes.
  2. Brush highlight tinting. The plan called for tinting the cursor outline with the active layer's color when `preview=true`; not yet implemented (still always cyan/orange).

🤖 Generated with Claude Code

aurpelai and others added 11 commits June 16, 2026 18:19
Introduces a per-glyph Layer array as the future authoritative source for
glyph pixel data, while keeping the legacy top-level pixel fields in sync
so no consumer needs to change yet. Dexie schema bumps to v2 and runs a
one-shot upgrade that wraps each glyph's existing bitmap into layers[0];
legacy fields are retained on the record for first-open rollback safety.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds pure layer transformation helpers (hitTestLayer, cycleHitLayer,
addLayer, removeLayer, reorderLayers, updateLayer, updateLayerPixels,
cloneLayers, syncLegacyFields) and reworks the editor slice and the
useUndoRedo hook to snapshot and restore the full layer stack.

Every existing pushUndo call site and glyph mutation now goes through
syncLegacyFields so the Stage A invariant (layers[0..n] flattened equals
the legacy pixel fields) holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PixelEditor's render loop now iterates over glyph.layers bottom-up,
honouring per-layer visibility and rendering each layer in either its
own preview tint (layer.preview true) or white (preview false). The
pencil and eraser write into the active layer's pixel buffer through
updateLayerPixels; the buffer grows lazily as before, but scoped to the
layer rather than the whole glyph.

Adds an activeLayerId auto-selection effect that picks the topmost
visible unlocked layer whenever the selected glyph changes or the
active id no longer points at a layer in the current glyph.

Move-tool behaviour is unchanged for now (every layer still follows the
drag); per-layer hit-testing arrives in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the whole-glyph grab logic with Figma-style per-layer
hit-testing. Hovering with the move tool picks the topmost visible
inked layer under the cursor; the grab outline now wraps just that
layer's bbox. Click-drag translates the picked layer (or all layers in
multiSelectLayerIds when multi-selection is active), leaving the other
layers fixed. Alt-click cycles through stacked layers underneath via
cycleHitLayer. Click on empty space falls back to canvas pan as before.

Adds unionLayerBounds in core/project/layers for computing the outline
rect. moveLayerOrigins captures the pre-drag (xoffset, yoffset) per
moved layer so the drag commit can write the new positions through
syncLegacyFields in a single store update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a right-docked LayerPanel inside the editor workspace. Each row
shows visibility, preview/white tint toggle, name, lock, and delete
controls; clicking the row sets it as the active layer (Shift/Cmd-click
toggles multi-select for whole-glyph move). The header has a + button
that adds a layer up to MAX_LAYERS_PER_GLYPH. All mutations go through
the pure layer helpers in core/project/layers and push a snapshot to
the undo stack before committing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
trimGlyph in pack.ts now composites the glyph's layers via flattenGlyph
before scanning for ink. useAtlas.packAtlas flattens on the main thread
before posting to the atlas worker so the worker continues to consume
the legacy single-bitmap shape and doesn't need its own copy of
flattenGlyph in its bundle.

Functionally a no-op today thanks to the Stage A invariant (legacy
fields already equal flattenGlyph), but unblocks Stage B's removal of
those legacy fields from the in-memory Glyph type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DEFAULT_LAYER_PALETTE[0] is now white so the auto-created base layer (the
only layer that ever uses defaultLayerColor(0)) renders identically to
the final exported ink. The magenta entry is dropped; the rest of the
palette (amber, cyan, lime, violet) shifts to indices 1–4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
applyPaint treated a blank layer's placeholder (0, 0) offset as real, so
the first stroke on a fresh layer anchored its buffer to the cell origin
instead of the brush footprint. Combined with no trim-on-write, layers
accumulated blank rows/columns on their top-left edge, which pushed the
move-tool grab outline far outside the visible ink.

Two fixes:
- applyPaint now treats a 0×0 layer specially and anchors the new buffer
  at the brush footprint rather than unioning with the placeholder (0, 0).
- updateLayerPixels runs the new trimLayerToInk pass before commit so a
  layer's width/height/xoffset/yoffset always hug its inked bbox.

EditorScreen also runs trimLayerToInk over every layer on load to repair
glyphs saved before this fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Layers" heading needed to match the docked side-rail style, not the
float-panel style. GlyphList uses "text-muted-foreground text-xs
font-medium" (dimmer); AtlasFloat/PreviewFloat use the brighter
foreground because they're transient floats with their own visual
weight. LayerPanel is a persistent side rail, so it matches GlyphList.
AtlasFloat is positioned absolutely with right-3, which used to mean
"right edge of the canvas area". After the LayerPanel landed on the right
side it became "right edge of the canvas + panel" — and the float ended
up sliding behind the panel.

Wrap PixelEditor in its own relative container alongside the floats so
right-3 / bottom-4 measure against the canvas again, and LayerPanel
sits outside that container as a true side rail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When AtlasFloat was anchored to the column that included the editor
toolbar, top-12 cleared the toolbar's height. After the float was moved
into a sibling-of-PixelEditor relative container (which already starts
below the toolbar), top-12 added an extra 48px gap. Match the right
inset (top-3 right-3) so the float sits symmetrically in the canvas
corner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aurpelai
aurpelai merged commit c623494 into main Jun 16, 2026
1 check passed
@aurpelai
aurpelai deleted the feat/layer-system branch June 16, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant