Per-glyph layers with Figma-style move tool - #62
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Test plan
Follow-ups (not in this PR)
🤖 Generated with Claude Code