From f7150e407d4bf2a808addeb1f66b79ea6bc0cfc9 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:19:12 +0300 Subject: [PATCH 01/11] feat: add layer data model and v2 migration 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) --- src/config/index.ts | 1 + src/config/layers.ts | 10 +++ src/core/atlas/pack.test.ts | 3 + src/core/project/glyphs.ts | 2 + src/core/project/index.ts | 1 + src/core/project/layers.test.ts | 130 +++++++++++++++++++++++++++ src/core/project/layers.ts | 140 +++++++++++++++++++++++++++++ src/core/project/portable.ts | 44 +++++++-- src/core/project/types.ts | 24 ++++- src/db/db.test.ts | 68 ++++++++++++++ src/db/db.ts | 33 +++++++ src/features/home/ImportWizard.tsx | 26 +++++- 12 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 src/config/layers.ts create mode 100644 src/core/project/layers.test.ts create mode 100644 src/core/project/layers.ts create mode 100644 src/db/db.test.ts diff --git a/src/config/index.ts b/src/config/index.ts index 6a3f6d5..04e7d92 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,6 +1,7 @@ export * from './atlas'; export * from './editor-layout'; export * from './editor-state'; +export * from './layers'; export * from './pixel-editor'; export * from './preview'; export * from './project-defaults'; diff --git a/src/config/layers.ts b/src/config/layers.ts new file mode 100644 index 0000000..e6645f5 --- /dev/null +++ b/src/config/layers.ts @@ -0,0 +1,10 @@ +export const MAX_LAYERS_PER_GLYPH = 5; + +// Editor tints for layers. Picked to stay distinguishable against the white canvas ink and against each other. +export const DEFAULT_LAYER_PALETTE = [ + 'oklch(0.78 0.17 30)', + 'oklch(0.80 0.16 200)', + 'oklch(0.75 0.20 330)', + 'oklch(0.83 0.18 120)', + 'oklch(0.75 0.18 270)', +] as const; diff --git a/src/core/atlas/pack.test.ts b/src/core/atlas/pack.test.ts index 269fa4f..c5e8944 100644 --- a/src/core/atlas/pack.test.ts +++ b/src/core/atlas/pack.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { makeBaseLayerFromBitmap } from '../project/layers'; import type { Glyph } from '../project/types'; import { chooseAtlasSize, packGlyphs } from './pack'; @@ -18,6 +19,7 @@ function makeGlyph( return { codePoint, projectId: 'project-1', + layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })], pixels, width, height, @@ -34,6 +36,7 @@ function filledGlyph(codePoint: number, width: number, height: number): Glyph { return { codePoint, projectId: 'project-1', + layers: [makeBaseLayerFromBitmap({ pixels, width, height, xoffset: 0, yoffset: 0 })], pixels, width, height, diff --git a/src/core/project/glyphs.ts b/src/core/project/glyphs.ts index f4f96e3..a38237d 100644 --- a/src/core/project/glyphs.ts +++ b/src/core/project/glyphs.ts @@ -1,3 +1,4 @@ +import { makeBlankLayer } from './layers'; import type { Glyph } from './types'; export function makeBlankGlyph( @@ -9,6 +10,7 @@ export function makeBlankGlyph( return { codePoint, projectId, + layers: [makeBlankLayer()], pixels: new Uint8Array(width * height), width, height, diff --git a/src/core/project/index.ts b/src/core/project/index.ts index cc67429..2cebe5f 100644 --- a/src/core/project/index.ts +++ b/src/core/project/index.ts @@ -1,4 +1,5 @@ export * from './glyphs'; +export * from './layers'; export * from './portable'; export * from './project'; export * from './threshold'; diff --git a/src/core/project/layers.test.ts b/src/core/project/layers.test.ts new file mode 100644 index 0000000..9efc4a5 --- /dev/null +++ b/src/core/project/layers.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; + +import { flattenGlyph, makeBaseLayerFromBitmap, makeBlankLayer } from './layers'; +import type { Glyph, Layer } from './types'; + +function makeGlyph(layers: Layer[]): Glyph { + return { + codePoint: 0x41, + projectId: 'project-1', + layers, + pixels: new Uint8Array(0), + width: 0, + height: 0, + xoffset: 0, + yoffset: 0, + xadvance: 0, + isDirty: false, + }; +} + +function inkLayer(width: number, height: number, xoffset: number, yoffset: number, ink: number[]): Layer { + const pixels = new Uint8Array(width * height); + + ink.forEach((value, index) => { + pixels[index] = value; + }); + + return { + ...makeBlankLayer(), + pixels, + width, + height, + xoffset, + yoffset, + }; +} + +describe('flattenGlyph', () => { + it('returns a 0×0 result when every layer is empty', () => { + const glyph = makeGlyph([makeBlankLayer(), makeBlankLayer({ index: 1 })]); + const result = flattenGlyph(glyph); + + expect(result.width).toBe(0); + expect(result.height).toBe(0); + expect(result.pixels.length).toBe(0); + expect(result.xoffset).toBe(0); + expect(result.yoffset).toBe(0); + }); + + it('flattens a single layer to its own bounds and offset', () => { + const layer = inkLayer(2, 2, 3, 5, [10, 20, 30, 40]); + const result = flattenGlyph(makeGlyph([layer])); + + expect(result.width).toBe(2); + expect(result.height).toBe(2); + expect(result.xoffset).toBe(3); + expect(result.yoffset).toBe(5); + expect(Array.from(result.pixels)).toEqual([10, 20, 30, 40]); + }); + + it('takes the max value when two layers overlap', () => { + const lower = inkLayer(2, 1, 0, 0, [50, 200]); + const upper = inkLayer(2, 1, 1, 0, [255, 10]); + const result = flattenGlyph(makeGlyph([lower, upper])); + + // Union bbox: x ∈ [0, 3), y ∈ [0, 1) + expect(result.width).toBe(3); + expect(result.height).toBe(1); + expect(result.xoffset).toBe(0); + expect(result.yoffset).toBe(0); + // lower contributes [50, 200, 0]; upper contributes [_, 255, 10]; max wins. + expect(Array.from(result.pixels)).toEqual([50, 255, 10]); + }); + + it('handles negative offsets and unions correctly', () => { + const a = inkLayer(1, 1, -2, -1, [128]); + const b = inkLayer(1, 1, 3, 4, [64]); + const result = flattenGlyph(makeGlyph([a, b])); + + expect(result.xoffset).toBe(-2); + expect(result.yoffset).toBe(-1); + expect(result.width).toBe(6); + expect(result.height).toBe(6); + // a sits at (0, 0) in the result buffer; b at (5, 5). + expect(result.pixels[0]).toBe(128); + expect(result.pixels[5 * 6 + 5]).toBe(64); + }); + + it('skips hidden layers by default', () => { + const visibleLayer = inkLayer(1, 1, 0, 0, [200]); + const hiddenLayer = { ...inkLayer(1, 1, 5, 5, [100]), visible: false }; + const result = flattenGlyph(makeGlyph([visibleLayer, hiddenLayer])); + + expect(result.width).toBe(1); + expect(result.height).toBe(1); + expect(Array.from(result.pixels)).toEqual([200]); + }); + + it('includes hidden layers when includeHidden is true', () => { + const visibleLayer = inkLayer(1, 1, 0, 0, [200]); + const hiddenLayer = { ...inkLayer(1, 1, 2, 0, [100]), visible: false }; + const result = flattenGlyph(makeGlyph([visibleLayer, hiddenLayer]), { includeHidden: true }); + + expect(result.width).toBe(3); + expect(Array.from(result.pixels)).toEqual([200, 0, 100]); + }); +}); + +describe('makeBaseLayerFromBitmap', () => { + it('copies the source bitmap so later mutation does not bleed through', () => { + const source = new Uint8Array([1, 2, 3, 4]); + const layer = makeBaseLayerFromBitmap({ + pixels: source, + width: 2, + height: 2, + xoffset: 7, + yoffset: 9, + }); + + source[0] = 99; + + expect(layer.pixels[0]).toBe(1); + expect(layer.width).toBe(2); + expect(layer.height).toBe(2); + expect(layer.xoffset).toBe(7); + expect(layer.yoffset).toBe(9); + expect(layer.name).toBe('Base'); + expect(layer.visible).toBe(true); + }); +}); diff --git a/src/core/project/layers.ts b/src/core/project/layers.ts new file mode 100644 index 0000000..fd67542 --- /dev/null +++ b/src/core/project/layers.ts @@ -0,0 +1,140 @@ +import { DEFAULT_LAYER_PALETTE } from '@/config'; + +import type { Glyph, Layer } from './types'; + +export function defaultLayerColor(index: number): string { + return DEFAULT_LAYER_PALETTE[index % DEFAULT_LAYER_PALETTE.length]; +} + +interface MakeBlankLayerOptions { + name?: string; + color?: string; + index?: number; +} + +export function makeBlankLayer(options: MakeBlankLayerOptions = {}): Layer { + const index = options.index ?? 0; + + return { + id: crypto.randomUUID(), + name: options.name ?? (index === 0 ? 'Base' : `Layer ${index + 1}`), + pixels: new Uint8Array(0), + width: 0, + height: 0, + xoffset: 0, + yoffset: 0, + visible: true, + preview: true, + color: options.color ?? defaultLayerColor(index), + locked: false, + }; +} + +interface MakeBaseLayerFromBitmapInput { + pixels: Uint8Array; + width: number; + height: number; + xoffset: number; + yoffset: number; +} + +export function makeBaseLayerFromBitmap(input: MakeBaseLayerFromBitmapInput): Layer { + return { + id: crypto.randomUUID(), + name: 'Base', + pixels: new Uint8Array(input.pixels), + width: input.width, + height: input.height, + xoffset: input.xoffset, + yoffset: input.yoffset, + visible: true, + preview: true, + color: defaultLayerColor(0), + locked: false, + }; +} + +export interface FlattenedGlyph { + pixels: Uint8Array; + width: number; + height: number; + xoffset: number; + yoffset: number; +} + +interface FlattenOptions { + includeHidden?: boolean; +} + +/** + * Composite a glyph's layers into the legacy single-bitmap shape that the export pipeline consumes. + * + * The union bbox of contributing layers becomes the result's rect; each layer's pixels are written + * with max-blending (matches the binarised render path — there is no real alpha blending in the editor). + * An all-empty glyph flattens to a 0×0 buffer at offset (0, 0). + */ +export function flattenGlyph(glyph: Glyph, options: FlattenOptions = {}): FlattenedGlyph { + const includeHidden = options.includeHidden ?? false; + const contributing = glyph.layers.filter( + (layer) => (includeHidden || layer.visible) && layer.width > 0 && layer.height > 0, + ); + + if (contributing.length === 0) { + return { pixels: new Uint8Array(0), width: 0, height: 0, xoffset: 0, yoffset: 0 }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const layer of contributing) { + if (layer.xoffset < minX) { + minX = layer.xoffset; + } + + if (layer.yoffset < minY) { + minY = layer.yoffset; + } + + if (layer.xoffset + layer.width > maxX) { + maxX = layer.xoffset + layer.width; + } + + if (layer.yoffset + layer.height > maxY) { + maxY = layer.yoffset + layer.height; + } + } + + const width = maxX - minX; + const height = maxY - minY; + const pixels = new Uint8Array(width * height); + + for (const layer of contributing) { + const layerDX = layer.xoffset - minX; + const layerDY = layer.yoffset - minY; + + for (let row = 0; row < layer.height; row++) { + const destinationY = layerDY + row; + const sourceRowStart = row * layer.width; + const destinationRowStart = destinationY * width; + + for (let column = 0; column < layer.width; column++) { + const sourceValue = layer.pixels[sourceRowStart + column]; + + if (sourceValue === 0) { + continue; + } + + const destinationIndex = destinationRowStart + layerDX + column; + const existing = pixels[destinationIndex]; + + if (sourceValue > existing) { + pixels[destinationIndex] = sourceValue; + } + } + } + } + + return { pixels, width, height, xoffset: minX, yoffset: minY }; +} diff --git a/src/core/project/portable.ts b/src/core/project/portable.ts index 3a6f177..3aa5f16 100644 --- a/src/core/project/portable.ts +++ b/src/core/project/portable.ts @@ -1,10 +1,12 @@ +import { makeBaseLayerFromBitmap } from './layers'; import type { Glyph, Project } from './types'; export interface PortableProject { version: 1; project: Project; - // Pixels serialized as base64 strings to survive JSON round-trip - glyphs: Array & { pixels: string }>; + // Pixels serialized as base64 strings to survive JSON round-trip. + // Layers are intentionally not serialized in v1 — they're reconstructed from the legacy bitmap on import. + glyphs: Array & { pixels: string }>; } function toBase64(buf: Uint8Array): string { @@ -32,7 +34,22 @@ export function exportPortableProject(project: Project, glyphs: Glyph[]): string const portable: PortableProject = { version: 1, project, - glyphs: glyphs.map((glyph) => ({ ...glyph, pixels: toBase64(glyph.pixels) })), + glyphs: glyphs.map((glyph) => { + // Drop `layers` from the serialized payload; v1 portable bundles store the legacy bitmap shape only. + const rest: Omit = { + codePoint: glyph.codePoint, + projectId: glyph.projectId, + width: glyph.width, + height: glyph.height, + xoffset: glyph.xoffset, + yoffset: glyph.yoffset, + xadvance: glyph.xadvance, + isDirty: glyph.isDirty, + alphaThreshold: glyph.alphaThreshold, + }; + + return { ...rest, pixels: toBase64(glyph.pixels) }; + }), }; return JSON.stringify(portable, null, 2); @@ -49,10 +66,23 @@ export function importPortableProject(json: string): { project: Project; glyphs: throw new Error('Invalid project bundle: missing project data'); } - const glyphs: Glyph[] = data.glyphs.map((glyph) => ({ - ...glyph, - pixels: fromBase64(glyph.pixels), - })); + const glyphs: Glyph[] = data.glyphs.map((glyph) => { + const pixels = fromBase64(glyph.pixels); + + return { + ...glyph, + pixels, + layers: [ + makeBaseLayerFromBitmap({ + pixels, + width: glyph.width, + height: glyph.height, + xoffset: glyph.xoffset, + yoffset: glyph.yoffset, + }), + ], + }; + }); return { project: data.project, glyphs }; } diff --git a/src/core/project/types.ts b/src/core/project/types.ts index 04d3e6a..239b733 100644 --- a/src/core/project/types.ts +++ b/src/core/project/types.ts @@ -29,17 +29,35 @@ export interface Project { glyphs: number[]; // ordered list of Unicode code points } +export interface Layer { + id: string; + name: string; + pixels: Uint8Array; // 8-bit greyscale, width × height bytes + width: number; + height: number; + xoffset: number; + yoffset: number; + visible: boolean; + // When true the editor renders the layer in its tint colour; when false it renders white (final look). + preview: boolean; + // Editor-only tint colour (e.g. CSS oklch string). Ignored at export time. + color: string; + locked: boolean; +} + export interface Glyph { codePoint: number; projectId: string; - pixels: Uint8Array; // 8-bit greyscale, width × height bytes + // Stage A invariant: layers[0] mirrors the legacy top-level pixel fields exactly. + // Stage B will make layers authoritative and drop the legacy fields. + layers: Layer[]; + pixels: Uint8Array; width: number; height: number; xoffset: number; yoffset: number; xadvance: number; - isDirty: boolean; // true when edited vs font-derived original - // Optional per-glyph alpha cutoff override. Undefined → use FontSettings.alphaThreshold. + isDirty: boolean; alphaThreshold?: number; } diff --git a/src/db/db.test.ts b/src/db/db.test.ts new file mode 100644 index 0000000..437711a --- /dev/null +++ b/src/db/db.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; + +import type { Glyph } from '@/core/project/types'; + +import { upgradeGlyphV1ToV2 } from './db'; + +function makeV1Record(): Glyph & { id: string } { + const pixels = new Uint8Array([10, 20, 30, 40, 50, 60]); + + // Cast through unknown to model a real v1 record (no `layers` field present). + return { + id: 'project-1:65', + codePoint: 65, + projectId: 'project-1', + pixels, + width: 3, + height: 2, + xoffset: 4, + yoffset: 7, + xadvance: 9, + isDirty: true, + } as unknown as Glyph & { id: string }; +} + +describe('upgradeGlyphV1ToV2', () => { + it('wraps the legacy bitmap into a single base layer', () => { + const record = makeV1Record(); + + upgradeGlyphV1ToV2(record); + + expect(record.layers.length).toBe(1); + const baseLayer = record.layers[0]; + + expect(baseLayer.name).toBe('Base'); + expect(baseLayer.width).toBe(3); + expect(baseLayer.height).toBe(2); + expect(baseLayer.xoffset).toBe(4); + expect(baseLayer.yoffset).toBe(7); + expect(Array.from(baseLayer.pixels)).toEqual([10, 20, 30, 40, 50, 60]); + expect(baseLayer.visible).toBe(true); + expect(baseLayer.preview).toBe(true); + expect(baseLayer.locked).toBe(false); + }); + + it('preserves legacy fields for one-shot rollback safety', () => { + const record = makeV1Record(); + const originalPixelsRef = record.pixels; + + upgradeGlyphV1ToV2(record); + + expect(record.pixels).toBe(originalPixelsRef); + expect(record.width).toBe(3); + expect(record.height).toBe(2); + expect(record.xoffset).toBe(4); + expect(record.yoffset).toBe(7); + expect(record.xadvance).toBe(9); + expect(record.isDirty).toBe(true); + }); + + it('detaches the layer bitmap from the legacy buffer so later edits do not desync mid-transaction', () => { + const record = makeV1Record(); + + upgradeGlyphV1ToV2(record); + record.pixels[0] = 255; + + expect(record.layers[0].pixels[0]).toBe(10); + }); +}); diff --git a/src/db/db.ts b/src/db/db.ts index f6164ef..0bec3d8 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,6 +1,7 @@ import Dexie, { type EntityTable } from 'dexie'; import type { Glyph, Project } from '@/core/project'; +import { makeBaseLayerFromBitmap } from '@/core/project/layers'; interface FontFile { id: string; @@ -9,6 +10,25 @@ interface FontFile { createdAt: number; } +/** + * v1→v2 per-record upgrade: derive layers[0] from the legacy bitmap fields. + * Legacy pixels/width/height/xoffset/yoffset are intentionally preserved so a + * one-shot rollback to v1 remains safe. + * + * Exported for unit testing without spinning up a real IndexedDB. + */ +export function upgradeGlyphV1ToV2(record: Glyph & { id: string }): void { + record.layers = [ + makeBaseLayerFromBitmap({ + pixels: record.pixels, + width: record.width, + height: record.height, + xoffset: record.xoffset, + yoffset: record.yoffset, + }), + ]; +} + class BmfDatabase extends Dexie { projects!: EntityTable; glyphs!: EntityTable; @@ -23,6 +43,19 @@ class BmfDatabase extends Dexie { glyphs: '[projectId+codePoint], projectId, id', fontFiles: 'id', }); + + this.version(2) + .stores({ + projects: 'id, updatedAt', + glyphs: '[projectId+codePoint], projectId, id', + fontFiles: 'id', + }) + .upgrade((transaction) => + transaction + .table('glyphs') + .toCollection() + .modify((record) => upgradeGlyphV1ToV2(record)), + ); } } diff --git a/src/features/home/ImportWizard.tsx b/src/features/home/ImportWizard.tsx index e1a4735..b88daca 100644 --- a/src/features/home/ImportWizard.tsx +++ b/src/features/home/ImportWizard.tsx @@ -14,7 +14,12 @@ import { import type { BmfParseResult } from '@/core/bmf/parse'; import { parseBmfText } from '@/core/bmf/parse'; import type { RasterizedGlyph } from '@/core/font/rasterize'; -import { createProject, makeBlankGlyph } from '@/core/project'; +import { + createProject, + makeBaseLayerFromBitmap, + makeBlankGlyph, + makeBlankLayer, +} from '@/core/project'; import { GLYPH_SETS } from '@/core/project/glyphSets'; import type { Glyph } from '@/core/project/types'; import { saveFontFile, saveGlyphs } from '@/db/glyphs'; @@ -77,6 +82,7 @@ function sliceGlyphsFromAtlas( return { codePoint: char.id, projectId, + layers: [makeBlankLayer()], pixels: new Uint8Array(0), width: 0, height: 0, @@ -106,6 +112,15 @@ function sliceGlyphsFromAtlas( return { codePoint: char.id, projectId, + layers: [ + makeBaseLayerFromBitmap({ + pixels, + width: char.width, + height: char.height, + xoffset: char.xoffset, + yoffset: char.yoffset, + }), + ], pixels, width: char.width, height: char.height, @@ -359,6 +374,15 @@ export const ImportWizard = ({ open, onOpenChange }: Props): React.JSX.Element = const glyphs: Glyph[] = (previewGlyphs as RasterizedGlyph[]).map((rasterizedGlyph) => ({ codePoint: rasterizedGlyph.codePoint, projectId: project.id, + layers: [ + makeBaseLayerFromBitmap({ + pixels: rasterizedGlyph.pixels, + width: rasterizedGlyph.width, + height: rasterizedGlyph.height, + xoffset: rasterizedGlyph.xoffset, + yoffset: rasterizedGlyph.yoffset, + }), + ], pixels: rasterizedGlyph.pixels, width: rasterizedGlyph.width, height: rasterizedGlyph.height, From 73a0e34a58b16b499d56e858e0e0ae39f00dcb6d Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:23:35 +0300 Subject: [PATCH 02/11] feat: layer-aware store, mutators, and undo 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) --- src/core/project/layers.test.ts | 175 +++++++++++++++- src/core/project/layers.ts | 193 +++++++++++++++++- src/features/editor/EditorScreen.tsx | 22 +- src/features/editor/glyph-list/GlyphList.tsx | 43 ++-- .../editor/pixel-editor/PixelEditor.tsx | 17 +- src/hooks/useUndoRedo.ts | 37 +--- src/store/editorSlice.ts | 18 +- 7 files changed, 428 insertions(+), 77 deletions(-) diff --git a/src/core/project/layers.test.ts b/src/core/project/layers.test.ts index 9efc4a5..5bb87ef 100644 --- a/src/core/project/layers.test.ts +++ b/src/core/project/layers.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from 'vitest'; -import { flattenGlyph, makeBaseLayerFromBitmap, makeBlankLayer } from './layers'; +import { MAX_LAYERS_PER_GLYPH } from '@/config'; + +import { + addLayer, + cycleHitLayer, + flattenGlyph, + hitTestLayer, + makeBaseLayerFromBitmap, + makeBlankLayer, + removeLayer, + reorderLayers, + syncLegacyFields, + updateLayer, + updateLayerPixels, +} from './layers'; import type { Glyph, Layer } from './types'; function makeGlyph(layers: Layer[]): Glyph { @@ -128,3 +142,162 @@ describe('makeBaseLayerFromBitmap', () => { expect(layer.visible).toBe(true); }); }); + +describe('hitTestLayer', () => { + it('returns the topmost visible layer with ink at the cell', () => { + const lower = inkLayer(2, 1, 0, 0, [200, 200]); + const upper = { ...inkLayer(2, 1, 1, 0, [255, 0]), id: 'upper' }; + const result = hitTestLayer(makeGlyph([lower, upper]), 1, 0, 128); + + expect(result?.id).toBe('upper'); + }); + + it('skips hidden and locked layers', () => { + const hidden = { ...inkLayer(1, 1, 0, 0, [255]), id: 'hidden', visible: false }; + const locked = { ...inkLayer(1, 1, 0, 0, [255]), id: 'locked', locked: true }; + const visible = { ...inkLayer(1, 1, 0, 0, [255]), id: 'visible' }; + const result = hitTestLayer(makeGlyph([hidden, locked, visible]), 0, 0, 128); + + expect(result?.id).toBe('visible'); + }); + + it('returns null when no layer is inked under the cell', () => { + const layer = inkLayer(2, 1, 0, 0, [0, 0]); + + expect(hitTestLayer(makeGlyph([layer]), 0, 0, 128)).toBeNull(); + }); + + it('respects the threshold value', () => { + const layer = inkLayer(1, 1, 0, 0, [100]); + + expect(hitTestLayer(makeGlyph([layer]), 0, 0, 50)).not.toBeNull(); + expect(hitTestLayer(makeGlyph([layer]), 0, 0, 150)).toBeNull(); + }); +}); + +describe('cycleHitLayer', () => { + it('returns the next layer beneath the current one under the cursor', () => { + const a = { ...inkLayer(1, 1, 0, 0, [255]), id: 'a' }; + const b = { ...inkLayer(1, 1, 0, 0, [255]), id: 'b' }; + const c = { ...inkLayer(1, 1, 0, 0, [255]), id: 'c' }; + // layers ordered bottom-to-top: a, b, c. hits iterate top→bottom: [c, b, a]. + const glyph = makeGlyph([a, b, c]); + + expect(cycleHitLayer(glyph, 0, 0, 128, 'c')?.id).toBe('b'); + expect(cycleHitLayer(glyph, 0, 0, 128, 'b')?.id).toBe('a'); + expect(cycleHitLayer(glyph, 0, 0, 128, 'a')?.id).toBe('c'); + }); + + it('returns the only hit when just one layer is inked', () => { + const a = { ...inkLayer(1, 1, 0, 0, [255]), id: 'a' }; + + expect(cycleHitLayer(makeGlyph([a]), 0, 0, 128, 'a')?.id).toBe('a'); + }); +}); + +describe('layer mutators', () => { + it('addLayer appends a fresh layer up to the cap', () => { + let glyph = makeGlyph([makeBlankLayer()]); + + for (let index = 1; index < MAX_LAYERS_PER_GLYPH; index++) { + glyph = addLayer(glyph); + } + + expect(glyph.layers.length).toBe(MAX_LAYERS_PER_GLYPH); + + const capped = addLayer(glyph); + + expect(capped.layers.length).toBe(MAX_LAYERS_PER_GLYPH); + expect(capped).toBe(glyph); + }); + + it('removeLayer refuses to leave a glyph with zero layers', () => { + const onlyLayer = makeBlankLayer(); + const glyph = makeGlyph([onlyLayer]); + const result = removeLayer(glyph, onlyLayer.id); + + expect(result.layers.length).toBe(1); + expect(result).toBe(glyph); + }); + + it('removeLayer drops the matching layer', () => { + const first = makeBlankLayer({ index: 0 }); + const second = makeBlankLayer({ index: 1 }); + const result = removeLayer(makeGlyph([first, second]), second.id); + + expect(result.layers.length).toBe(1); + expect(result.layers[0].id).toBe(first.id); + }); + + it('reorderLayers moves a layer to a new index', () => { + const a = makeBlankLayer({ index: 0 }); + const b = makeBlankLayer({ index: 1 }); + const c = makeBlankLayer({ index: 2 }); + const result = reorderLayers(makeGlyph([a, b, c]), 0, 2); + + expect(result.layers.map((layer) => layer.id)).toEqual([b.id, c.id, a.id]); + }); + + it('updateLayer patches the matching layer without touching others', () => { + const a = makeBlankLayer({ index: 0 }); + const b = makeBlankLayer({ index: 1 }); + const result = updateLayer(makeGlyph([a, b]), b.id, { visible: false, name: 'Renamed' }); + + expect(result.layers[0]).toBe(a); + expect(result.layers[1].visible).toBe(false); + expect(result.layers[1].name).toBe('Renamed'); + }); + + it('updateLayerPixels writes the new buffer and rect on the target layer', () => { + const original = makeBlankLayer(); + const glyph = makeGlyph([original]); + const newPixels = new Uint8Array([1, 2, 3, 4]); + const result = updateLayerPixels(glyph, original.id, { + pixels: newPixels, + width: 2, + height: 2, + xoffset: 5, + yoffset: 6, + }); + + expect(result.layers[0].pixels).toBe(newPixels); + expect(result.layers[0].width).toBe(2); + expect(result.layers[0].xoffset).toBe(5); + }); + + it('layer mutators keep the legacy fields in sync with flattenGlyph', () => { + const layer = inkLayer(2, 1, 3, 4, [255, 128]); + const result = updateLayer(makeGlyph([layer]), layer.id, { xoffset: 10 }); + + expect(result.width).toBe(2); + expect(result.height).toBe(1); + expect(result.xoffset).toBe(10); + expect(result.yoffset).toBe(4); + expect(Array.from(result.pixels)).toEqual([255, 128]); + }); +}); + +describe('syncLegacyFields', () => { + it('rewrites the legacy bitmap fields to match the layer stack', () => { + const layer = inkLayer(2, 1, 7, 8, [99, 100]); + const stale: Glyph = { + codePoint: 0x41, + projectId: 'project-1', + layers: [layer], + pixels: new Uint8Array([0, 0, 0, 0]), + width: 9999, + height: 9999, + xoffset: 9999, + yoffset: 9999, + xadvance: 0, + isDirty: false, + }; + const result = syncLegacyFields(stale); + + expect(result.width).toBe(2); + expect(result.height).toBe(1); + expect(result.xoffset).toBe(7); + expect(result.yoffset).toBe(8); + expect(Array.from(result.pixels)).toEqual([99, 100]); + }); +}); diff --git a/src/core/project/layers.ts b/src/core/project/layers.ts index fd67542..37efaf5 100644 --- a/src/core/project/layers.ts +++ b/src/core/project/layers.ts @@ -1,4 +1,4 @@ -import { DEFAULT_LAYER_PALETTE } from '@/config'; +import { DEFAULT_LAYER_PALETTE, MAX_LAYERS_PER_GLYPH } from '@/config'; import type { Glyph, Layer } from './types'; @@ -138,3 +138,194 @@ export function flattenGlyph(glyph: Glyph, options: FlattenOptions = {}): Flatte return { pixels, width, height, xoffset: minX, yoffset: minY }; } + +/** Deep-clones a layer stack (independent pixel buffers) for use as an undo snapshot. */ +export function cloneLayers(layers: Layer[]): Layer[] { + return layers.map((layer) => ({ ...layer, pixels: new Uint8Array(layer.pixels) })); +} + +/** + * Refresh the legacy top-level pixel fields on a Glyph from its `layers` array. + * + * Maintains the Stage A invariant: every Glyph in memory and at rest has its + * legacy fields equal to flattenGlyph(glyph). Will be removed in Stage B once + * the editor and export pipeline read `layers` directly. + */ +export function syncLegacyFields(glyph: Glyph): Glyph { + const flat = flattenGlyph(glyph); + + return { + ...glyph, + pixels: flat.pixels, + width: flat.width, + height: flat.height, + xoffset: flat.xoffset, + yoffset: flat.yoffset, + }; +} + +/** Returns the topmost visible inked layer under the given cell (in glyph cell-space), or null. */ +export function hitTestLayer(glyph: Glyph, cellX: number, cellY: number, threshold: number): Layer | null { + for (let layerIndex = glyph.layers.length - 1; layerIndex >= 0; layerIndex--) { + const layer = glyph.layers[layerIndex]; + + if (!layer.visible || layer.locked) { + continue; + } + + const localX = cellX - layer.xoffset; + const localY = cellY - layer.yoffset; + + if (localX < 0 || localY < 0 || localX >= layer.width || localY >= layer.height) { + continue; + } + + const value = layer.pixels[localY * layer.width + localX]; + + if (value >= threshold) { + return layer; + } + } + + return null; +} + +/** + * Returns the layer directly beneath `currentLayerId` under the given cell, wrapping to the top + * of the stack if `currentLayerId` is the bottommost hit. Used to cycle the move tool's grab target. + */ +export function cycleHitLayer( + glyph: Glyph, + cellX: number, + cellY: number, + threshold: number, + currentLayerId: string, +): Layer | null { + const hits: Layer[] = []; + + for (let layerIndex = glyph.layers.length - 1; layerIndex >= 0; layerIndex--) { + const layer = glyph.layers[layerIndex]; + + if (!layer.visible || layer.locked) { + continue; + } + + const localX = cellX - layer.xoffset; + const localY = cellY - layer.yoffset; + + if (localX < 0 || localY < 0 || localX >= layer.width || localY >= layer.height) { + continue; + } + + if (layer.pixels[localY * layer.width + localX] >= threshold) { + hits.push(layer); + } + } + + if (hits.length <= 1) { + return hits[0] ?? null; + } + + const currentIndex = hits.findIndex((layer) => layer.id === currentLayerId); + + if (currentIndex === -1) { + return hits[0]; + } + + return hits[(currentIndex + 1) % hits.length]; +} + +function replaceLayer(glyph: Glyph, layerId: string, replacement: Layer): Glyph { + return syncLegacyFields({ + ...glyph, + layers: glyph.layers.map((layer) => (layer.id === layerId ? replacement : layer)), + }); +} + +export function addLayer(glyph: Glyph): Glyph { + if (glyph.layers.length >= MAX_LAYERS_PER_GLYPH) { + return glyph; + } + + const newLayer = makeBlankLayer({ index: glyph.layers.length }); + + return syncLegacyFields({ ...glyph, layers: [...glyph.layers, newLayer] }); +} + +export function removeLayer(glyph: Glyph, layerId: string): Glyph { + if (glyph.layers.length <= 1) { + return glyph; + } + + const next = glyph.layers.filter((layer) => layer.id !== layerId); + + if (next.length === glyph.layers.length) { + return glyph; + } + + return syncLegacyFields({ ...glyph, layers: next }); +} + +export function reorderLayers(glyph: Glyph, fromIndex: number, toIndex: number): Glyph { + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= glyph.layers.length || + toIndex >= glyph.layers.length + ) { + return glyph; + } + + const next = [...glyph.layers]; + const [moved] = next.splice(fromIndex, 1); + + next.splice(toIndex, 0, moved); + + return syncLegacyFields({ ...glyph, layers: next }); +} + +interface LayerPatch { + name?: string; + visible?: boolean; + preview?: boolean; + color?: string; + locked?: boolean; + xoffset?: number; + yoffset?: number; +} + +export function updateLayer(glyph: Glyph, layerId: string, patch: LayerPatch): Glyph { + const target = glyph.layers.find((layer) => layer.id === layerId); + + if (!target) { + return glyph; + } + + return replaceLayer(glyph, layerId, { ...target, ...patch }); +} + +export interface LayerPixelsPatch { + pixels: Uint8Array; + width: number; + height: number; + xoffset: number; + yoffset: number; +} + +export function updateLayerPixels(glyph: Glyph, layerId: string, patch: LayerPixelsPatch): Glyph { + const target = glyph.layers.find((layer) => layer.id === layerId); + + if (!target) { + return glyph; + } + + return replaceLayer(glyph, layerId, { + ...target, + pixels: patch.pixels, + width: patch.width, + height: patch.height, + xoffset: patch.xoffset, + yoffset: patch.yoffset, + }); +} diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index 344e3eb..46a9c7e 100644 --- a/src/features/editor/EditorScreen.tsx +++ b/src/features/editor/EditorScreen.tsx @@ -10,6 +10,7 @@ import { GLYPH_LIST_MIN_WIDTH_PX, ZOOM_REFERENCE, } from '@/config'; +import { cloneLayers, syncLegacyFields } from '@/core/project/layers'; import { getGlyphsForProject } from '@/db/glyphs'; import { saveGlyphs } from '@/db/glyphs'; import { ExportDialog } from '@/features/export/ExportDialog'; @@ -247,20 +248,19 @@ export const EditorScreen = (): React.JSX.Element => { const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0; const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0; - pushUndo(selectedCodePoint, { - pixels: new Uint8Array(glyph.pixels), - width: glyph.width, - height: glyph.height, - xoffset: glyph.xoffset, - yoffset: glyph.yoffset, - }); + pushUndo(selectedCodePoint, { layers: cloneLayers(glyph.layers) }); - const updated = { + // Arrow-key nudge moves every layer together (preserves the whole-glyph translation behaviour). + const shiftedLayers = glyph.layers.map((layer) => ({ + ...layer, + xoffset: layer.xoffset + dx, + yoffset: layer.yoffset + dy, + })); + const updated = syncLegacyFields({ ...glyph, - xoffset: glyph.xoffset + dx, - yoffset: glyph.yoffset + dy, + layers: shiftedLayers, isDirty: true, - }; + }); upsertGlyph(updated); void saveGlyphs([updated]); diff --git a/src/features/editor/glyph-list/GlyphList.tsx b/src/features/editor/glyph-list/GlyphList.tsx index bf7ee66..5e45a68 100644 --- a/src/features/editor/glyph-list/GlyphList.tsx +++ b/src/features/editor/glyph-list/GlyphList.tsx @@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label'; import { Slider } from '@/components/ui/slider'; import type { Glyph } from '@/core/project'; import { makeBlankGlyph } from '@/core/project'; +import { cloneLayers, makeBaseLayerFromBitmap, syncLegacyFields } from '@/core/project/layers'; import { deleteGlyph, getFontFile, saveGlyphs } from '@/db'; import { useRasterize } from '@/hooks/useRasterize'; import { cn } from '@/lib/utils'; @@ -129,16 +130,21 @@ export const GlyphList = ({ return; } - const updated: Glyph = { + // Reset from the source font discards all user-edited layers and replaces them with one fresh base layer. + const updated: Glyph = syncLegacyFields({ ...selectedGlyph, - pixels: rg.pixels, - width: rg.width, - height: rg.height, - xoffset: rg.xoffset, - yoffset: rg.yoffset, + layers: [ + makeBaseLayerFromBitmap({ + pixels: rg.pixels, + width: rg.width, + height: rg.height, + xoffset: rg.xoffset, + yoffset: rg.yoffset, + }), + ], xadvance: rg.xadvance, isDirty: false, - }; + }); upsertGlyph(updated); await saveGlyphs([updated]); @@ -167,18 +173,21 @@ export const GlyphList = ({ return; } - pushUndo(selectedGlyph.codePoint, { - pixels: new Uint8Array(selectedGlyph.pixels), - width: selectedGlyph.width, - height: selectedGlyph.height, - xoffset: selectedGlyph.xoffset, - yoffset: selectedGlyph.yoffset, - }); - const cleared: Glyph = { + pushUndo(selectedGlyph.codePoint, { layers: cloneLayers(selectedGlyph.layers) }); + // Clear every layer (preserve their metadata: id, name, color, visibility, etc). + const clearedLayers = selectedGlyph.layers.map((layer) => ({ + ...layer, + pixels: new Uint8Array(0), + width: 0, + height: 0, + xoffset: 0, + yoffset: 0, + })); + const cleared: Glyph = syncLegacyFields({ ...selectedGlyph, - pixels: new Uint8Array(selectedGlyph.width * selectedGlyph.height), + layers: clearedLayers, isDirty: true, - }; + }); upsertGlyph(cleared); await saveGlyphs([cleared]); diff --git a/src/features/editor/pixel-editor/PixelEditor.tsx b/src/features/editor/pixel-editor/PixelEditor.tsx index 9545c50..1fb9a33 100644 --- a/src/features/editor/pixel-editor/PixelEditor.tsx +++ b/src/features/editor/pixel-editor/PixelEditor.tsx @@ -17,6 +17,7 @@ import { ZOOM_PRESETS, ZOOM_WHEEL_SENSITIVITY, } from '@/config'; +import { cloneLayers } from '@/core/project/layers'; import { effectiveThreshold } from '@/core/project/threshold'; import type { Glyph } from '@/core/project/types'; import { saveGlyphs } from '@/db/glyphs'; @@ -794,13 +795,7 @@ export const PixelEditor = (): React.JSX.Element => { if (overGlyph && currentGlyph) { // Glyph drag — undoable. - pushUndo(currentGlyph.codePoint, { - pixels: new Uint8Array(currentGlyph.pixels), - width: currentGlyph.width, - height: currentGlyph.height, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }); + pushUndo(currentGlyph.codePoint, { layers: cloneLayers(currentGlyph.layers) }); stateRef.current.moveOrigin = { x: event.clientX, y: event.clientY, @@ -834,13 +829,7 @@ export const PixelEditor = (): React.JSX.Element => { } event.currentTarget.setPointerCapture(event.pointerId); - pushUndo(currentGlyph.codePoint, { - pixels: new Uint8Array(currentGlyph.pixels), - width: currentGlyph.width, - height: currentGlyph.height, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }); + pushUndo(currentGlyph.codePoint, { layers: cloneLayers(currentGlyph.layers) }); stateRef.current.isDrawing = true; stateRef.current.lastPixel = -1; stateRef.current.moveOrigin = null; diff --git a/src/hooks/useUndoRedo.ts b/src/hooks/useUndoRedo.ts index 6ee2034..1ec25da 100644 --- a/src/hooks/useUndoRedo.ts +++ b/src/hooks/useUndoRedo.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; +import { cloneLayers, syncLegacyFields } from '@/core/project/layers'; import { saveGlyphs } from '@/db/glyphs'; import { useStore } from '@/store'; @@ -37,27 +38,17 @@ export function useUndoRedo(): UseUndoRedoResult { return; } - const snapshot = undoAction(selectedCodePoint, { - pixels: new Uint8Array(glyph.pixels), - width: glyph.width, - height: glyph.height, - xoffset: glyph.xoffset, - yoffset: glyph.yoffset, - }); + const snapshot = undoAction(selectedCodePoint, { layers: cloneLayers(glyph.layers) }); if (!snapshot) { return; } - const updated = { + const updated = syncLegacyFields({ ...glyph, - pixels: snapshot.pixels, - width: snapshot.width, - height: snapshot.height, - xoffset: snapshot.xoffset, - yoffset: snapshot.yoffset, + layers: snapshot.layers, isDirty: true, - }; + }); upsertGlyph(updated); void saveGlyphs([updated]); @@ -74,27 +65,17 @@ export function useUndoRedo(): UseUndoRedoResult { return; } - const snapshot = redoAction(selectedCodePoint, { - pixels: new Uint8Array(glyph.pixels), - width: glyph.width, - height: glyph.height, - xoffset: glyph.xoffset, - yoffset: glyph.yoffset, - }); + const snapshot = redoAction(selectedCodePoint, { layers: cloneLayers(glyph.layers) }); if (!snapshot) { return; } - const updated = { + const updated = syncLegacyFields({ ...glyph, - pixels: snapshot.pixels, - width: snapshot.width, - height: snapshot.height, - xoffset: snapshot.xoffset, - yoffset: snapshot.yoffset, + layers: snapshot.layers, isDirty: true, - }; + }); upsertGlyph(updated); void saveGlyphs([updated]); diff --git a/src/store/editorSlice.ts b/src/store/editorSlice.ts index 25e2cc0..11ea1d0 100644 --- a/src/store/editorSlice.ts +++ b/src/store/editorSlice.ts @@ -9,15 +9,13 @@ import { ZOOM_DEFAULT, ZOOM_MAX, } from '@/config'; +import type { Layer } from '@/core/project'; export type EditorTool = 'pencil' | 'eraser' | 'move' | 'zoom'; +/** A frozen copy of a glyph's full layer stack — undo/redo restores this verbatim. */ export interface GlyphSnapshot { - pixels: Uint8Array; - width: number; - height: number; - xoffset: number; - yoffset: number; + layers: Layer[]; } export interface EditorSlice { @@ -29,6 +27,10 @@ export interface EditorSlice { // Monotonically incrementing counter bumped whenever the editor should // recenter the canvas inside its scroll container (zoom-to-fit, 100%, etc.). pendingRecenter: number; + // Currently focused layer per glyph (drawing tools target this layer). + activeLayerId: string | null; + // Layer IDs included in a multi-select. Move tool acts on all of them together. + multiSelectLayerIds: string[]; // Per-glyph undo stacks: codePoint → stack of glyph snapshots undoStacks: Record; redoStacks: Record; @@ -37,6 +39,8 @@ export interface EditorSlice { setBrushSize: (size: number) => void; setZoomLevel: (zoom: number) => void; setShowGrid: (show: boolean) => void; + setActiveLayerId: (id: string | null) => void; + setMultiSelectLayerIds: (ids: string[]) => void; requestRecenter: () => void; pushUndo: (codePoint: number, snapshot: GlyphSnapshot) => void; /** Undo the last edit; pass the current glyph state so it can be pushed onto the redo stack. */ @@ -52,6 +56,8 @@ export const createEditorSlice: StateCreator = (set, get) => ({ zoomLevel: ZOOM_DEFAULT, showGrid: INITIAL_SHOW_GRID, pendingRecenter: 0, + activeLayerId: null, + multiSelectLayerIds: [], undoStacks: {}, redoStacks: {}, setSelectedCodePoint: (codePoint) => set({ selectedCodePoint: codePoint }), @@ -59,6 +65,8 @@ export const createEditorSlice: StateCreator = (set, get) => ({ setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(MAX_BRUSH_SIZE, size)) }), setZoomLevel: (zoom) => set({ zoomLevel: Math.max(1, Math.min(ZOOM_MAX, zoom)) }), setShowGrid: (show) => set({ showGrid: show }), + setActiveLayerId: (id) => set({ activeLayerId: id }), + setMultiSelectLayerIds: (ids) => set({ multiSelectLayerIds: ids }), requestRecenter: () => set((state) => ({ pendingRecenter: state.pendingRecenter + 1 })), pushUndo: (codePoint, snapshot) => set((state) => { From f03ddbf508d0be39020a6b6517219a55fbf570a3 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:25:52 +0300 Subject: [PATCH 03/11] feat: render layers and target the active layer when drawing 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) --- .../editor/pixel-editor/PixelEditor.tsx | 148 +++++++++++++----- 1 file changed, 111 insertions(+), 37 deletions(-) diff --git a/src/features/editor/pixel-editor/PixelEditor.tsx b/src/features/editor/pixel-editor/PixelEditor.tsx index 1fb9a33..969348a 100644 --- a/src/features/editor/pixel-editor/PixelEditor.tsx +++ b/src/features/editor/pixel-editor/PixelEditor.tsx @@ -17,9 +17,9 @@ import { ZOOM_PRESETS, ZOOM_WHEEL_SENSITIVITY, } from '@/config'; -import { cloneLayers } from '@/core/project/layers'; +import { cloneLayers, updateLayerPixels } from '@/core/project/layers'; import { effectiveThreshold } from '@/core/project/threshold'; -import type { Glyph } from '@/core/project/types'; +import type { Glyph, Layer } from '@/core/project/types'; import { saveGlyphs } from '@/db/glyphs'; import { useStore } from '@/store'; @@ -54,6 +54,8 @@ export const PixelEditor = (): React.JSX.Element => { const pushUndo = useStore((state) => state.pushUndo); const addToast = useStore((state) => state.addToast); const currentProject = useStore((state) => state.currentProject); + const activeLayerId = useStore((state) => state.activeLayerId); + const setActiveLayerId = useStore((state) => state.setActiveLayerId); const autoSaveToastTimer = useRef | null>(null); const autoSaveToastShown = useRef(false); @@ -70,6 +72,7 @@ export const PixelEditor = (): React.JSX.Element => { brushSize, zoomLevel, showGrid, + activeLayerId: null as string | null, overGrab: false, isDrawing: false, lastPixel: -1, @@ -114,9 +117,30 @@ export const PixelEditor = (): React.JSX.Element => { stateRef.current.brushSize = brushSize; stateRef.current.zoomLevel = zoomLevel; stateRef.current.showGrid = showGrid; + stateRef.current.activeLayerId = activeLayerId; stateRef.current.overGrab = overGrab; }); + // Auto-select a layer when none is active (or when the active one no longer + // belongs to the selected glyph). Prefers the topmost visible unlocked layer. + useEffect(() => { + if (!glyph) { + return; + } + + const stillValid = glyph.layers.some((layer) => layer.id === activeLayerId); + + if (stillValid) { + return; + } + + const candidate = + [...glyph.layers].reverse().find((layer) => layer.visible && !layer.locked) ?? + glyph.layers[glyph.layers.length - 1]; + + setActiveLayerId(candidate?.id ?? null); + }, [glyph, activeLayerId, setActiveLayerId]); + const drawCanvas = useCallback(() => { const canvas = canvasRef.current; const currentGlyph = stateRef.current.glyph; @@ -207,31 +231,51 @@ export const PixelEditor = (): React.JSX.Element => { context.fillStyle = 'rgba(255,255,255,0.03)'; context.fillRect(cellX, cellY, fontSize * zoom, lineHeight * zoom); - // Glyph pixels — dimmed outside the cell; binarized at the effective threshold - if (currentGlyph && currentGlyph.width > 0 && currentGlyph.height > 0) { + // Glyph pixels — render each visible layer bottom-up. + // During a move drag every layer follows the cursor (whole-glyph move semantics + // for now; per-layer dragging arrives in a follow-up commit). + if (currentGlyph) { const threshold = effectiveThreshold(currentGlyph, project.settings); + // Move drag offsets the rendered layer position; layoutXoffset/Y are still the + // glyph-level offsets used by isOverGlyphGrab and the layout bbox. + const moveDX = renderXoffset - layoutXoffset; + const moveDY = renderYoffset - layoutYoffset; - for (let py = 0; py < currentGlyph.height; py++) { - for (let px = 0; px < currentGlyph.width; px++) { - const value = currentGlyph.pixels[py * currentGlyph.width + px]; + for (const layer of currentGlyph.layers) { + if (!layer.visible || layer.width === 0 || layer.height === 0) { + continue; + } - if (value < threshold) { - continue; + const layerOriginX = layer.xoffset + moveDX; + const layerOriginY = layer.yoffset + moveDY; + const inkColor = layer.preview ? layer.color : 'rgb(255,255,255)'; + + for (let row = 0; row < layer.height; row++) { + for (let column = 0; column < layer.width; column++) { + const value = layer.pixels[row * layer.width + column]; + + if (value < threshold) { + continue; + } + + const cellColumn = layerOriginX + column; + const cellRow = layerOriginY + row; + const canvasX = (cellColumn - originX) * zoom; + const canvasY = (cellRow - originY) * zoom; + const inCell = + cellColumn >= 0 && + cellColumn < fontSize && + cellRow >= 0 && + cellRow < lineHeight; + + context.globalAlpha = inCell ? 1 : 0.35; + context.fillStyle = inkColor; + context.fillRect(canvasX, canvasY, zoom, zoom); } - - const cx = (renderXoffset + px - originX) * zoom; - const cy = (renderYoffset + py - originY) * zoom; - const inCell = - renderXoffset + px >= 0 && - renderXoffset + px < fontSize && - renderYoffset + py >= 0 && - renderYoffset + py < lineHeight; - const alpha = inCell ? 1 : 0.35; - - context.fillStyle = `rgba(255,255,255,${alpha})`; - context.fillRect(cx, cy, zoom, zoom); } } + + context.globalAlpha = 1; } // Guide lines: baseline and cap-height @@ -654,6 +698,29 @@ export const PixelEditor = (): React.JSX.Element => { return cell.col >= left && cell.col < right && cell.row >= top && cell.row < bottom; } + function resolveTargetLayer(currentGlyph: Glyph): Layer | null { + const id = stateRef.current.activeLayerId; + + if (id) { + const match = currentGlyph.layers.find((layer) => layer.id === id); + + if (match) { + return match; + } + } + + // Fall back to the topmost visible unlocked layer. + for (let index = currentGlyph.layers.length - 1; index >= 0; index--) { + const layer = currentGlyph.layers[index]; + + if (layer.visible && !layer.locked) { + return layer; + } + } + + return null; + } + function applyPaint(cell: { col: number; row: number } | null): void { const currentGlyph = stateRef.current.glyph; const project = currentProject; @@ -662,6 +729,12 @@ export const PixelEditor = (): React.JSX.Element => { return; } + const targetLayer = resolveTargetLayer(currentGlyph); + + if (!targetLayer || targetLayer.locked) { + return; + } + const tool = stateRef.current.activeTool; const size = stateRef.current.brushSize; const half = Math.floor(size / 2); @@ -689,10 +762,10 @@ export const PixelEditor = (): React.JSX.Element => { return; } - const currentLeft = currentGlyph.xoffset; - const currentTop = currentGlyph.yoffset; - const currentRight = currentLeft + currentGlyph.width; - const currentBottom = currentTop + currentGlyph.height; + const currentLeft = targetLayer.xoffset; + const currentTop = targetLayer.yoffset; + const currentRight = currentLeft + targetLayer.width; + const currentBottom = currentTop + targetLayer.height; let newLeft = currentLeft; let newTop = currentTop; @@ -711,8 +784,8 @@ export const PixelEditor = (): React.JSX.Element => { const grew = newLeft !== currentLeft || newTop !== currentTop || - newWidth !== currentGlyph.width || - newHeight !== currentGlyph.height; + newWidth !== targetLayer.width || + newHeight !== targetLayer.height; let newPixels: Uint8Array; @@ -721,14 +794,14 @@ export const PixelEditor = (): React.JSX.Element => { const shiftX = currentLeft - newLeft; const shiftY = currentTop - newTop; - for (let row = 0; row < currentGlyph.height; row++) { - for (let col = 0; col < currentGlyph.width; col++) { + for (let row = 0; row < targetLayer.height; row++) { + for (let col = 0; col < targetLayer.width; col++) { newPixels[(row + shiftY) * newWidth + (col + shiftX)] = - currentGlyph.pixels[row * currentGlyph.width + col]; + targetLayer.pixels[row * targetLayer.width + col]; } } } else { - newPixels = new Uint8Array(currentGlyph.pixels); + newPixels = new Uint8Array(targetLayer.pixels); } let changed = grew; @@ -756,12 +829,13 @@ export const PixelEditor = (): React.JSX.Element => { } const updated: Glyph = { - ...currentGlyph, - pixels: newPixels, - width: newWidth, - height: newHeight, - xoffset: newLeft, - yoffset: newTop, + ...updateLayerPixels(currentGlyph, targetLayer.id, { + pixels: newPixels, + width: newWidth, + height: newHeight, + xoffset: newLeft, + yoffset: newTop, + }), isDirty: true, }; From b490a78987b96e582758acc8c31c6368d3103236 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:31:10 +0300 Subject: [PATCH 04/11] feat: layer-aware move tool with hit-testing 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) --- src/core/project/layers.ts | 52 ++++ .../editor/pixel-editor/PixelEditor.tsx | 233 +++++++++++------- 2 files changed, 200 insertions(+), 85 deletions(-) diff --git a/src/core/project/layers.ts b/src/core/project/layers.ts index 37efaf5..f1694d6 100644 --- a/src/core/project/layers.ts +++ b/src/core/project/layers.ts @@ -139,6 +139,58 @@ export function flattenGlyph(glyph: Glyph, options: FlattenOptions = {}): Flatte return { pixels, width, height, xoffset: minX, yoffset: minY }; } +export interface LayerBounds { + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * Union bbox of the named layers, optionally shifted by a (dx, dy) translation. + * Returns null if no inked layers were named (or all named layers are 0-sized). + */ +export function unionLayerBounds( + glyph: Glyph, + layerIds: readonly string[], + shift: { dx: number; dy: number } | null, +): LayerBounds | null { + if (layerIds.length === 0) { + return null; + } + + const dx = shift?.dx ?? 0; + const dy = shift?.dy ?? 0; + let left = Infinity; + let top = Infinity; + let right = -Infinity; + let bottom = -Infinity; + let found = false; + + for (const layer of glyph.layers) { + if (!layerIds.includes(layer.id) || layer.width === 0 || layer.height === 0) { + continue; + } + + const layerLeft = layer.xoffset + dx; + const layerTop = layer.yoffset + dy; + const layerRight = layerLeft + layer.width; + const layerBottom = layerTop + layer.height; + + if (layerLeft < left) {left = layerLeft;} + + if (layerTop < top) {top = layerTop;} + + if (layerRight > right) {right = layerRight;} + + if (layerBottom > bottom) {bottom = layerBottom;} + + found = true; + } + + return found ? { left, top, right, bottom } : null; +} + /** Deep-clones a layer stack (independent pixel buffers) for use as an undo snapshot. */ export function cloneLayers(layers: Layer[]): Layer[] { return layers.map((layer) => ({ ...layer, pixels: new Uint8Array(layer.pixels) })); diff --git a/src/features/editor/pixel-editor/PixelEditor.tsx b/src/features/editor/pixel-editor/PixelEditor.tsx index 969348a..05485db 100644 --- a/src/features/editor/pixel-editor/PixelEditor.tsx +++ b/src/features/editor/pixel-editor/PixelEditor.tsx @@ -17,7 +17,14 @@ import { ZOOM_PRESETS, ZOOM_WHEEL_SENSITIVITY, } from '@/config'; -import { cloneLayers, updateLayerPixels } from '@/core/project/layers'; +import { + cloneLayers, + cycleHitLayer, + hitTestLayer, + syncLegacyFields, + unionLayerBounds, + updateLayerPixels, +} from '@/core/project/layers'; import { effectiveThreshold } from '@/core/project/threshold'; import type { Glyph, Layer } from '@/core/project/types'; import { saveGlyphs } from '@/db/glyphs'; @@ -56,6 +63,7 @@ export const PixelEditor = (): React.JSX.Element => { const currentProject = useStore((state) => state.currentProject); const activeLayerId = useStore((state) => state.activeLayerId); const setActiveLayerId = useStore((state) => state.setActiveLayerId); + const multiSelectLayerIds = useStore((state) => state.multiSelectLayerIds); const autoSaveToastTimer = useRef | null>(null); const autoSaveToastShown = useRef(false); @@ -66,6 +74,7 @@ export const PixelEditor = (): React.JSX.Element => { // lifting it to React state keeps the cursor in sync without flicker. const [overGrab, setOverGrab] = useState(false); + const initialMoveLayerOrigins: Record = {}; const stateRef = useRef({ glyph, activeTool, @@ -73,11 +82,17 @@ export const PixelEditor = (): React.JSX.Element => { zoomLevel, showGrid, activeLayerId: null as string | null, + multiSelectLayerIds: [] as string[], overGrab: false, isDrawing: false, lastPixel: -1, cursorCell: null as { col: number; row: number } | null, - moveOrigin: null as { x: number; y: number; xoffset: number; yoffset: number } | null, + // Layer IDs the move tool would grab right now under the cursor. Recomputed on hover. + // Empty when the cursor is over empty space — in that case the move tool falls back to canvas pan. + hoverGrabLayerIds: [] as string[], + moveOrigin: null as { x: number; y: number } | null, + // Pre-drag offsets per moved layer, captured at pointerdown. + moveLayerOrigins: initialMoveLayerOrigins, moveDelta: { dx: 0, dy: 0 }, isPanning: false, panOrigin: null as { @@ -118,6 +133,7 @@ export const PixelEditor = (): React.JSX.Element => { stateRef.current.zoomLevel = zoomLevel; stateRef.current.showGrid = showGrid; stateRef.current.activeLayerId = activeLayerId; + stateRef.current.multiSelectLayerIds = multiSelectLayerIds; stateRef.current.overGrab = overGrab; }); @@ -155,29 +171,31 @@ export const PixelEditor = (): React.JSX.Element => { const { fontSize, lineHeight, base, capHeight } = project.settings; // During a move drag the store is not updated — offsets live only in stateRef. - // layoutXoffset/Y use the original offsets so the grid stays fixed. - // renderXoffset/Y apply the current drag delta for pixel rendering only. - const origin = stateRef.current.moveOrigin; + // Only the layers being dragged are shifted by (dx, dy); other layers stay put. + const moveLayerOrigins = stateRef.current.moveLayerOrigins; + const movingLayerIds = Object.keys(moveLayerOrigins); + const dragging = movingLayerIds.length > 0; const { dx, dy } = stateRef.current.moveDelta; const layoutXoffset = currentGlyph?.xoffset ?? 0; const layoutYoffset = currentGlyph?.yoffset ?? 0; - const renderXoffset = origin ? origin.xoffset + dx : layoutXoffset; - const renderYoffset = origin ? origin.yoffset + dy : layoutYoffset; - - // The layout bounding box covers both the original and dragged-to glyph - // position so panning during a drag never reveals empty canvas. + // dragBounds expands the layout to cover both the original and dragged glyph + // positions so panning during a drag never reveals empty canvas. We use the + // glyph-level (flat) bounds shifted by (dx, dy) as a slightly loose upper bound. const container = containerRef.current; const dragBounds = currentGlyph - ? { - xoffset: Math.min(layoutXoffset, renderXoffset), - yoffset: Math.min(layoutYoffset, renderYoffset), - width: - Math.max(layoutXoffset + currentGlyph.width, renderXoffset + currentGlyph.width) - - Math.min(layoutXoffset, renderXoffset), - height: - Math.max(layoutYoffset + currentGlyph.height, renderYoffset + currentGlyph.height) - - Math.min(layoutYoffset, renderYoffset), - } + ? dragging + ? { + xoffset: Math.min(layoutXoffset, layoutXoffset + dx), + yoffset: Math.min(layoutYoffset, layoutYoffset + dy), + width: currentGlyph.width + Math.abs(dx), + height: currentGlyph.height + Math.abs(dy), + } + : { + xoffset: layoutXoffset, + yoffset: layoutYoffset, + width: currentGlyph.width, + height: currentGlyph.height, + } : null; const layout = computeCanvasLayout( project.settings, @@ -232,22 +250,18 @@ export const PixelEditor = (): React.JSX.Element => { context.fillRect(cellX, cellY, fontSize * zoom, lineHeight * zoom); // Glyph pixels — render each visible layer bottom-up. - // During a move drag every layer follows the cursor (whole-glyph move semantics - // for now; per-layer dragging arrives in a follow-up commit). + // Layers being dragged are shifted by (dx, dy); other layers stay at rest. if (currentGlyph) { const threshold = effectiveThreshold(currentGlyph, project.settings); - // Move drag offsets the rendered layer position; layoutXoffset/Y are still the - // glyph-level offsets used by isOverGlyphGrab and the layout bbox. - const moveDX = renderXoffset - layoutXoffset; - const moveDY = renderYoffset - layoutYoffset; for (const layer of currentGlyph.layers) { if (!layer.visible || layer.width === 0 || layer.height === 0) { continue; } - const layerOriginX = layer.xoffset + moveDX; - const layerOriginY = layer.yoffset + moveDY; + const beingMoved = layer.id in moveLayerOrigins; + const layerOriginX = layer.xoffset + (beingMoved ? dx : 0); + const layerOriginY = layer.yoffset + (beingMoved ? dy : 0); const inkColor = layer.preview ? layer.color : 'rgb(255,255,255)'; for (let row = 0; row < layer.height; row++) { @@ -323,30 +337,26 @@ export const PixelEditor = (): React.JSX.Element => { context.restore(); } - // Move-tool grab outline — drawn around the glyph's pixel rect when the - // cursor is over the grab region. Skipped while actively dragging the glyph. - if ( - stateRef.current.activeTool === 'move' && - stateRef.current.overGrab && - !stateRef.current.moveOrigin && - currentGlyph && - currentGlyph.width > 0 && - currentGlyph.height > 0 - ) { - // Outline encloses the full grab region — the glyph rect expanded by - // GLYPH_GRAB_PADDING_CELLS on every side, matching isOverGlyphGrab. - const padding = GLYPH_GRAB_PADDING_CELLS; - const rectX = (renderXoffset - padding - originX) * zoom + 0.5; - const rectY = (renderYoffset - padding - originY) * zoom + 0.5; - const rectWidth = (currentGlyph.width + padding * 2) * zoom - 1; - const rectHeight = (currentGlyph.height + padding * 2) * zoom - 1; - - context.save(); - context.strokeStyle = GLYPH_GRAB_OUTLINE_COLOR; - context.lineWidth = 1; - context.setLineDash([]); - context.strokeRect(rectX, rectY, rectWidth, rectHeight); - context.restore(); + // Move-tool grab outline — wraps the layers under the cursor (or being + // dragged). Skipped if the move tool isn't active or no layers are picked. + if (stateRef.current.activeTool === 'move' && currentGlyph) { + const outlineIds = dragging ? movingLayerIds : stateRef.current.hoverGrabLayerIds; + const outlineBox = unionLayerBounds(currentGlyph, outlineIds, dragging ? { dx, dy } : null); + + if (outlineBox) { + const padding = GLYPH_GRAB_PADDING_CELLS; + const rectX = (outlineBox.left - padding - originX) * zoom + 0.5; + const rectY = (outlineBox.top - padding - originY) * zoom + 0.5; + const rectWidth = (outlineBox.right - outlineBox.left + padding * 2) * zoom - 1; + const rectHeight = (outlineBox.bottom - outlineBox.top + padding * 2) * zoom - 1; + + context.save(); + context.strokeStyle = GLYPH_GRAB_OUTLINE_COLOR; + context.lineWidth = 1; + context.setLineDash([]); + context.strokeRect(rectX, rectY, rectWidth, rectHeight); + context.restore(); + } } // Grid overlay (cell area only) @@ -677,25 +687,28 @@ export const PixelEditor = (): React.JSX.Element => { return cellFromClientPoint(event.clientX, event.clientY); } - // Move-tool hit-test: is the cell inside the glyph's pixel rect, expanded by - // GLYPH_GRAB_PADDING_CELLS for a forgiving target on thin glyphs? Empty - // glyphs (0×0) fall back to a single-cell grab target at the offset point. - function isOverGlyphGrab(cell: { col: number; row: number } | null): boolean { + /** + * Returns the layer IDs the move tool would grab at the given cell. + * - If the user has a multi-selection in the layer panel, return those IDs (whole-glyph-style move). + * - Otherwise hit-test the topmost visible inked layer under the cursor. + * - Returns [] if nothing is under the cursor → move tool falls back to canvas pan. + */ + function moveGrabLayerIds(cell: { col: number; row: number } | null): string[] { const currentGlyph = stateRef.current.glyph; + const project = currentProject; + + if (!cell || !currentGlyph || !project) { + return []; + } - if (!cell || !currentGlyph) { - return false; + if (stateRef.current.multiSelectLayerIds.length > 0) { + return stateRef.current.multiSelectLayerIds; } - const padding = GLYPH_GRAB_PADDING_CELLS; - const width = Math.max(1, currentGlyph.width); - const height = Math.max(1, currentGlyph.height); - const left = currentGlyph.xoffset - padding; - const top = currentGlyph.yoffset - padding; - const right = currentGlyph.xoffset + width + padding; - const bottom = currentGlyph.yoffset + height + padding; + const threshold = effectiveThreshold(currentGlyph, project.settings); + const hit = hitTestLayer(currentGlyph, cell.col, cell.row, threshold); - return cell.col >= left && cell.col < right && cell.row >= top && cell.row < bottom; + return hit ? [hit.id] : []; } function resolveTargetLayer(currentGlyph: Glyph): Layer | null { @@ -860,22 +873,53 @@ export const PixelEditor = (): React.JSX.Element => { return; } - // Move tool — branches into glyph-drag (over the glyph rect) or canvas-pan. + // Move tool — branches into layer-drag (over an inked layer) or canvas-pan. if (stateRef.current.activeTool === 'move') { const cell = cellFromEvent(event); - const overGlyph = isOverGlyphGrab(cell); + let targetIds = moveGrabLayerIds(cell); + const project = currentProject; + + // Alt+click cycles to the next layer underneath the current pick (Figma-like + // "click through"). Only meaningful when not in multi-select mode. + if ( + targetIds.length === 1 && + event.altKey && + currentGlyph && + project && + cell && + stateRef.current.multiSelectLayerIds.length === 0 + ) { + const threshold = effectiveThreshold(currentGlyph, project.settings); + const next = cycleHitLayer(currentGlyph, cell.col, cell.row, threshold, targetIds[0]); + + if (next) { + targetIds = [next.id]; + } + } event.currentTarget.setPointerCapture(event.pointerId); - if (overGlyph && currentGlyph) { - // Glyph drag — undoable. + if (targetIds.length > 0 && currentGlyph) { + // Layer drag — undoable. pushUndo(currentGlyph.codePoint, { layers: cloneLayers(currentGlyph.layers) }); - stateRef.current.moveOrigin = { - x: event.clientX, - y: event.clientY, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }; + + const origins: Record = {}; + + for (const layer of currentGlyph.layers) { + if (targetIds.includes(layer.id)) { + origins[layer.id] = { xoffset: layer.xoffset, yoffset: layer.yoffset }; + } + } + + stateRef.current.moveOrigin = { x: event.clientX, y: event.clientY }; + stateRef.current.moveLayerOrigins = origins; + + // Promote a single-layer hit to the active layer so subsequent paint + // strokes target the layer the user just grabbed. + if (targetIds.length === 1 && stateRef.current.multiSelectLayerIds.length === 0) { + setActiveLayerId(targetIds[0]); + } + stateRef.current.isDrawing = true; (event.currentTarget as HTMLCanvasElement).style.cursor = 'grabbing'; } else { @@ -925,7 +969,7 @@ export const PixelEditor = (): React.JSX.Element => { return; } - // Active glyph drag — move tool with moveOrigin set. + // Active layer drag — move tool with moveOrigin set. if (stateRef.current.activeTool === 'move' && stateRef.current.moveOrigin) { const origin = stateRef.current.moveOrigin; const zoom = stateRef.current.zoomLevel; @@ -939,15 +983,24 @@ export const PixelEditor = (): React.JSX.Element => { return; } - // Move tool, hovering — recompute over-glyph state for the outline + cursor. + // Move tool, hovering — recompute the picked layer(s) for the outline + cursor. if (stateRef.current.activeTool === 'move') { const cell = cellFromEvent(event); - const overGlyph = isOverGlyphGrab(cell); + const grabIds = moveGrabLayerIds(cell); + const wasGrabbing = stateRef.current.hoverGrabLayerIds; + const changed = + grabIds.length !== wasGrabbing.length || + grabIds.some((id, index) => id !== wasGrabbing[index]); stateRef.current.cursorCell = cell; + stateRef.current.hoverGrabLayerIds = grabIds; + + const overGlyph = grabIds.length > 0; if (overGlyph !== stateRef.current.overGrab) { setOverGrab(overGlyph); + } else if (changed) { + drawCanvas(); } return; @@ -967,6 +1020,7 @@ export const PixelEditor = (): React.JSX.Element => { function onPointerLeave(): void { stateRef.current.cursorCell = null; + stateRef.current.hoverGrabLayerIds = []; if (stateRef.current.overGrab) { setOverGrab(false); @@ -1003,16 +1057,24 @@ export const PixelEditor = (): React.JSX.Element => { let didSave = false; if (stateRef.current.activeTool === 'move' && currentGlyph && stateRef.current.moveOrigin) { - const { xoffset, yoffset } = stateRef.current.moveOrigin; const { dx, dy } = stateRef.current.moveDelta; + const origins = stateRef.current.moveLayerOrigins; + + if ((dx !== 0 || dy !== 0) && Object.keys(origins).length > 0) { + const shiftedLayers = currentGlyph.layers.map((layer) => { + const origin = origins[layer.id]; + + if (!origin) { + return layer; + } - if (dx !== 0 || dy !== 0) { - const updated: Glyph = { + return { ...layer, xoffset: origin.xoffset + dx, yoffset: origin.yoffset + dy }; + }); + const updated: Glyph = syncLegacyFields({ ...currentGlyph, - xoffset: xoffset + dx, - yoffset: yoffset + dy, + layers: shiftedLayers, isDirty: true, - }; + }); upsertGlyph(updated); void saveGlyphs([updated]); @@ -1043,6 +1105,7 @@ export const PixelEditor = (): React.JSX.Element => { stateRef.current.isDrawing = false; stateRef.current.lastPixel = -1; stateRef.current.moveOrigin = null; + stateRef.current.moveLayerOrigins = {}; stateRef.current.moveDelta = { dx: 0, dy: 0 }; } From e3781a8e00e933553ffa93e036815abae2fd51a5 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:32:50 +0300 Subject: [PATCH 05/11] feat: layer panel UI 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) --- src/features/editor/EditorScreen.tsx | 6 +- .../editor/pixel-editor/LayerPanel.tsx | 213 ++++++++++++++++++ 2 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/features/editor/pixel-editor/LayerPanel.tsx diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index 46a9c7e..b46cb0e 100644 --- a/src/features/editor/EditorScreen.tsx +++ b/src/features/editor/EditorScreen.tsx @@ -21,6 +21,7 @@ import type { EditorTool } from '@/store/editorSlice'; import { AtlasFloat } from './AtlasFloat'; import { GlyphList } from './glyph-list/GlyphList'; import { HelpOverlay } from './HelpOverlay'; +import { LayerPanel } from './pixel-editor/LayerPanel'; import { PixelEditor } from './pixel-editor/PixelEditor'; import { zoomToFitLevel } from './pixel-editor/zoom-helpers'; import { PreviewFloat } from './PreviewFloat'; @@ -419,7 +420,10 @@ export const EditorScreen = (): React.JSX.Element => { onAtlasToggle={() => setAtlasOpen((value) => !value)} onPreviewToggle={() => setPreviewOpen((value) => !value)} /> - +
+ + +
setAtlasOpen(false)} /> setPreviewOpen(false)} /> diff --git a/src/features/editor/pixel-editor/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx new file mode 100644 index 0000000..ad0b61e --- /dev/null +++ b/src/features/editor/pixel-editor/LayerPanel.tsx @@ -0,0 +1,213 @@ +import { Eye, EyeOff, Lock, Palette, Plus, Trash2, Unlock } from 'lucide-react'; +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { MAX_LAYERS_PER_GLYPH } from '@/config'; +import { + addLayer, + cloneLayers, + removeLayer, + updateLayer, +} from '@/core/project/layers'; +import { saveGlyphs } from '@/db/glyphs'; +import { cn } from '@/lib/utils'; +import { useStore } from '@/store'; + +export const LayerPanel = (): React.JSX.Element | null => { + const glyphs = useStore((state) => state.glyphs); + const selectedCodePoint = useStore((state) => state.selectedCodePoint); + const activeLayerId = useStore((state) => state.activeLayerId); + const setActiveLayerId = useStore((state) => state.setActiveLayerId); + const multiSelectLayerIds = useStore((state) => state.multiSelectLayerIds); + const setMultiSelectLayerIds = useStore((state) => state.setMultiSelectLayerIds); + const upsertGlyph = useStore((state) => state.upsertGlyph); + const pushUndo = useStore((state) => state.pushUndo); + + const glyph = glyphs.find((glyphItem) => glyphItem.codePoint === selectedCodePoint); + + if (!glyph) { + return null; + } + + function commit(next: typeof glyph, snapshot = true): void { + if (!glyph || !next) { + return; + } + + if (snapshot) { + pushUndo(glyph.codePoint, { layers: cloneLayers(glyph.layers) }); + } + + upsertGlyph(next); + void saveGlyphs([next]); + } + + function handleAddLayer(): void { + if (!glyph || glyph.layers.length >= MAX_LAYERS_PER_GLYPH) { + return; + } + + const next = addLayer(glyph); + + if (next === glyph) { + return; + } + + const added = next.layers[next.layers.length - 1]; + + commit({ ...next, isDirty: true }); + setActiveLayerId(added.id); + } + + function handleRemoveLayer(layerId: string): void { + if (!glyph || glyph.layers.length <= 1) { + return; + } + + const next = removeLayer(glyph, layerId); + + if (next === glyph) { + return; + } + + commit({ ...next, isDirty: true }); + + if (activeLayerId === layerId) { + setActiveLayerId(next.layers[next.layers.length - 1].id); + } + + if (multiSelectLayerIds.includes(layerId)) { + setMultiSelectLayerIds(multiSelectLayerIds.filter((id) => id !== layerId)); + } + } + + function handleToggle(layerId: string, key: 'visible' | 'preview' | 'locked'): void { + if (!glyph) { + return; + } + + const target = glyph.layers.find((layer) => layer.id === layerId); + + if (!target) { + return; + } + + const next = updateLayer(glyph, layerId, { [key]: !target[key] }); + + commit({ ...next, isDirty: true }); + } + + function handleSelectLayer(layerId: string, additive: boolean): void { + if (additive) { + const exists = multiSelectLayerIds.includes(layerId); + const next = exists + ? multiSelectLayerIds.filter((id) => id !== layerId) + : [...multiSelectLayerIds, layerId]; + + setMultiSelectLayerIds(next); + + return; + } + + setActiveLayerId(layerId); + + if (multiSelectLayerIds.length > 0) { + setMultiSelectLayerIds([]); + } + } + + // Render top-to-bottom: visually the topmost layer (last in array) appears at the top of the panel. + const renderedLayers = [...glyph.layers].reverse(); + const atCap = glyph.layers.length >= MAX_LAYERS_PER_GLYPH; + + return ( + + ); +}; From 6113c6bc2b47dd726a839b591305e00a45cd250d Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:34:20 +0300 Subject: [PATCH 06/11] feat: flatten layers before atlas packing 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) --- src/core/atlas/pack.ts | 6 +++++- src/hooks/useAtlas.ts | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/core/atlas/pack.ts b/src/core/atlas/pack.ts index aca6444..0516d1b 100644 --- a/src/core/atlas/pack.ts +++ b/src/core/atlas/pack.ts @@ -1,5 +1,6 @@ import { ATLAS_CANDIDATES } from '@/config'; +import { flattenGlyph } from '../project/layers'; import { effectiveThreshold } from '../project/threshold'; import type { FontSettings, Glyph, GlyphPlacement } from '../project/types'; import { pack } from './maxrects'; @@ -27,7 +28,10 @@ interface TrimmedGlyph { } function trimGlyph(glyph: Glyph, threshold: number): TrimmedGlyph { - const { width, height, pixels } = glyph; + // Flatten the layer stack first — the export pipeline only cares about the + // composited result, not how the user organised their layers. + const flat = flattenGlyph(glyph); + const { width, height, pixels } = flat; let minX = width, maxX = -1, diff --git a/src/hooks/useAtlas.ts b/src/hooks/useAtlas.ts index f3d9465..4b61e96 100644 --- a/src/hooks/useAtlas.ts +++ b/src/hooks/useAtlas.ts @@ -1,5 +1,6 @@ import { useCallback, useRef } from 'react'; +import { flattenGlyph } from '@/core/project/layers'; import type { Glyph, GlyphPlacement } from '@/core/project/types'; import type { AtlasWorkerRequest, AtlasWorkerResponse } from '@/workers/atlas.worker'; @@ -80,9 +81,25 @@ export function useAtlas(): { const id = crypto.randomUUID(); pendingRef.current.set(id, { resolve, reject }); + // Flatten each glyph's layers into the legacy single-bitmap shape before + // posting to the worker. Keeps the worker's input contract stable and + // means Stage B (which drops the legacy top-level fields from the in-memory + // Glyph type) won't need to ship flattenGlyph into the worker bundle. + const flattenedGlyphs: Glyph[] = glyphs.map((glyph) => { + const flat = flattenGlyph(glyph); + + return { + ...glyph, + pixels: flat.pixels, + width: flat.width, + height: flat.height, + xoffset: flat.xoffset, + yoffset: flat.yoffset, + }; + }); const req: AtlasWorkerRequest = { id, - glyphs, + glyphs: flattenedGlyphs, atlasWidth: 0, atlasHeight: 0, padding, From d945955751a7b807a8e64b1b9cfb017c854ea07d Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:41:33 +0300 Subject: [PATCH 07/11] feat: white base layer, drop magenta from layer palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/config/layers.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/config/layers.ts b/src/config/layers.ts index e6645f5..8f70ef2 100644 --- a/src/config/layers.ts +++ b/src/config/layers.ts @@ -1,10 +1,12 @@ export const MAX_LAYERS_PER_GLYPH = 5; -// Editor tints for layers. Picked to stay distinguishable against the white canvas ink and against each other. +// Editor tints for layers. Index 0 is white so the auto-created base layer +// renders identically to the final exported ink; subsequent indices are +// distinct hues so additional layers stand out from each other and the base. export const DEFAULT_LAYER_PALETTE = [ + 'rgb(255,255,255)', 'oklch(0.78 0.17 30)', 'oklch(0.80 0.16 200)', - 'oklch(0.75 0.20 330)', 'oklch(0.83 0.18 120)', 'oklch(0.75 0.18 270)', ] as const; From ec640d6a81aab785f94a8e468cec93c2ab0f66f4 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:47:51 +0300 Subject: [PATCH 08/11] fix: anchor empty layers at first paint and trim buffers to inked bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/core/project/layers.test.ts | 60 ++++++++++++++ src/core/project/layers.ts | 80 ++++++++++++++++++- src/features/editor/EditorScreen.tsx | 15 +++- .../editor/pixel-editor/PixelEditor.tsx | 11 ++- 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/src/core/project/layers.test.ts b/src/core/project/layers.test.ts index 5bb87ef..b875ea1 100644 --- a/src/core/project/layers.test.ts +++ b/src/core/project/layers.test.ts @@ -12,6 +12,7 @@ import { removeLayer, reorderLayers, syncLegacyFields, + trimLayerToInk, updateLayer, updateLayerPixels, } from './layers'; @@ -277,6 +278,65 @@ describe('layer mutators', () => { }); }); +describe('trimLayerToInk', () => { + it('crops a buffer with blank top-left rows and columns, shifting the offset to compensate', () => { + // 4×3 buffer with ink at (2, 1) and (3, 2); the inked bbox is x=2..3, y=1..2. + const pixels = new Uint8Array(4 * 3); + + pixels[1 * 4 + 2] = 200; + pixels[2 * 4 + 3] = 150; + + const layer: Layer = { + ...makeBlankLayer(), + pixels, + width: 4, + height: 3, + xoffset: 10, + yoffset: 20, + }; + const result = trimLayerToInk(layer); + + expect(result.width).toBe(2); + expect(result.height).toBe(2); + expect(result.xoffset).toBe(12); + expect(result.yoffset).toBe(21); + // Trimmed buffer: row 0 = [200, 0]; row 1 = [0, 150] + expect(Array.from(result.pixels)).toEqual([200, 0, 0, 150]); + }); + + it('collapses a fully-erased non-empty buffer to 0×0 at the origin', () => { + const layer: Layer = { + ...makeBlankLayer(), + pixels: new Uint8Array(4), + width: 2, + height: 2, + xoffset: 5, + yoffset: 7, + }; + const result = trimLayerToInk(layer); + + expect(result.width).toBe(0); + expect(result.height).toBe(0); + expect(result.xoffset).toBe(0); + expect(result.yoffset).toBe(0); + expect(result.pixels.length).toBe(0); + }); + + it('returns the same reference when the buffer already hugs its ink', () => { + const layer = inkLayer(2, 2, 0, 0, [10, 20, 30, 40]); + const result = trimLayerToInk(layer); + + expect(result).toBe(layer); + }); + + it('returns the same reference for a layer that is already 0×0', () => { + const layer = makeBlankLayer(); + const result = trimLayerToInk(layer); + + expect(result).toBe(layer); + }); +}); + describe('syncLegacyFields', () => { it('rewrites the legacy bitmap fields to match the layer stack', () => { const layer = inkLayer(2, 1, 7, 8, [99, 100]); diff --git a/src/core/project/layers.ts b/src/core/project/layers.ts index f1694d6..2f98683 100644 --- a/src/core/project/layers.ts +++ b/src/core/project/layers.ts @@ -196,6 +196,78 @@ export function cloneLayers(layers: Layer[]): Layer[] { return layers.map((layer) => ({ ...layer, pixels: new Uint8Array(layer.pixels) })); } +/** + * Crops a layer's pixel buffer to its non-zero bounds, adjusting xoffset/yoffset + * to keep the visible ink in the same world-space location. A fully-empty layer + * collapses to width=0, height=0 at offset (0, 0). Returns the same layer object + * when no trim is needed, so consumers can compare references to detect changes. + */ +export function trimLayerToInk(layer: Layer): Layer { + if (layer.width === 0 || layer.height === 0) { + return layer; + } + + let minX = layer.width; + let minY = layer.height; + let maxX = -1; + let maxY = -1; + + for (let row = 0; row < layer.height; row++) { + for (let column = 0; column < layer.width; column++) { + if (layer.pixels[row * layer.width + column] === 0) { + continue; + } + + if (column < minX) {minX = column;} + + if (column > maxX) {maxX = column;} + + if (row < minY) {minY = row;} + + if (row > maxY) {maxY = row;} + } + } + + if (maxX === -1) { + if (layer.width === 0 && layer.height === 0) { + return layer; + } + + return { + ...layer, + pixels: new Uint8Array(0), + width: 0, + height: 0, + xoffset: 0, + yoffset: 0, + }; + } + + const trimmedWidth = maxX - minX + 1; + const trimmedHeight = maxY - minY + 1; + + if (trimmedWidth === layer.width && trimmedHeight === layer.height) { + return layer; + } + + const trimmed = new Uint8Array(trimmedWidth * trimmedHeight); + + for (let row = 0; row < trimmedHeight; row++) { + const sourceStart = (minY + row) * layer.width + minX; + + trimmed.set(layer.pixels.subarray(sourceStart, sourceStart + trimmedWidth), row * trimmedWidth); + } + + return { + ...layer, + pixels: trimmed, + width: trimmedWidth, + height: trimmedHeight, + xoffset: layer.xoffset + minX, + yoffset: layer.yoffset + minY, + }; +} + /** * Refresh the legacy top-level pixel fields on a Glyph from its `layers` array. * @@ -372,7 +444,10 @@ export function updateLayerPixels(glyph: Glyph, layerId: string, patch: LayerPix return glyph; } - return replaceLayer(glyph, layerId, { + // Trim the new buffer to its inked bounds so the layer's rect always hugs the + // visible ink. Without this the buffer can keep blank rows/columns on its top + // or left edge — which mispositions the move-tool grab outline. + const trimmed = trimLayerToInk({ ...target, pixels: patch.pixels, width: patch.width, @@ -380,4 +455,7 @@ export function updateLayerPixels(glyph: Glyph, layerId: string, patch: LayerPix xoffset: patch.xoffset, yoffset: patch.yoffset, }); + + return replaceLayer(glyph, layerId, trimmed); } + diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index b46cb0e..86d40d6 100644 --- a/src/features/editor/EditorScreen.tsx +++ b/src/features/editor/EditorScreen.tsx @@ -10,7 +10,7 @@ import { GLYPH_LIST_MIN_WIDTH_PX, ZOOM_REFERENCE, } from '@/config'; -import { cloneLayers, syncLegacyFields } from '@/core/project/layers'; +import { cloneLayers, syncLegacyFields, trimLayerToInk } from '@/core/project/layers'; import { getGlyphsForProject } from '@/db/glyphs'; import { saveGlyphs } from '@/db/glyphs'; import { ExportDialog } from '@/features/export/ExportDialog'; @@ -116,7 +116,18 @@ export const EditorScreen = (): React.JSX.Element => { return; } - void getGlyphsForProject(currentProject.id).then(setGlyphs); + void getGlyphsForProject(currentProject.id).then((loaded) => + // Re-tighten every layer's buffer to its inked bounds on load — repairs + // any glyphs saved before the trim-on-write fix in updateLayerPixels. + setGlyphs( + loaded.map((loadedGlyph) => + syncLegacyFields({ + ...loadedGlyph, + layers: loadedGlyph.layers.map((layer) => trimLayerToInk(layer)), + }), + ), + ), + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentProject?.id]); diff --git a/src/features/editor/pixel-editor/PixelEditor.tsx b/src/features/editor/pixel-editor/PixelEditor.tsx index 05485db..77c8e44 100644 --- a/src/features/editor/pixel-editor/PixelEditor.tsx +++ b/src/features/editor/pixel-editor/PixelEditor.tsx @@ -775,10 +775,13 @@ export const PixelEditor = (): React.JSX.Element => { return; } - const currentLeft = targetLayer.xoffset; - const currentTop = targetLayer.yoffset; - const currentRight = currentLeft + targetLayer.width; - const currentBottom = currentTop + targetLayer.height; + // An empty layer (0×0) has no meaningful offset; anchor the new buffer at + // the brush footprint instead of unioning with the placeholder (0, 0) origin. + const layerIsEmpty = targetLayer.width === 0 || targetLayer.height === 0; + const currentLeft = layerIsEmpty ? clipLeft : targetLayer.xoffset; + const currentTop = layerIsEmpty ? clipTop : targetLayer.yoffset; + const currentRight = layerIsEmpty ? clipRight : currentLeft + targetLayer.width; + const currentBottom = layerIsEmpty ? clipBottom : currentTop + targetLayer.height; let newLeft = currentLeft; let newTop = currentTop; From 410ed59c90804b01e0ebde6e01e4dd1cc3a6d675 Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 18:50:26 +0300 Subject: [PATCH 09/11] refactor: match LayerPanel heading style to GlyphList 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. --- src/features/editor/pixel-editor/LayerPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/editor/pixel-editor/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx index ad0b61e..42766a3 100644 --- a/src/features/editor/pixel-editor/LayerPanel.tsx +++ b/src/features/editor/pixel-editor/LayerPanel.tsx @@ -126,7 +126,7 @@ export const LayerPanel = (): React.JSX.Element | null => { className="border-border bg-card flex w-56 shrink-0 flex-col border-l" >
- Layers + Layers