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..8f70ef2 --- /dev/null +++ b/src/config/layers.ts @@ -0,0 +1,12 @@ +export const MAX_LAYERS_PER_GLYPH = 5; + +// 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.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/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/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..b875ea1 --- /dev/null +++ b/src/core/project/layers.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from 'vitest'; + +import { MAX_LAYERS_PER_GLYPH } from '@/config'; + +import { + addLayer, + cycleHitLayer, + flattenGlyph, + hitTestLayer, + makeBaseLayerFromBitmap, + makeBlankLayer, + removeLayer, + reorderLayers, + syncLegacyFields, + trimLayerToInk, + updateLayer, + updateLayerPixels, +} 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); + }); +}); + +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('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]); + 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 new file mode 100644 index 0000000..2f98683 --- /dev/null +++ b/src/core/project/layers.ts @@ -0,0 +1,461 @@ +import { DEFAULT_LAYER_PALETTE, MAX_LAYERS_PER_GLYPH } 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 }; +} + +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) })); +} + +/** + * 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. + * + * 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; + } + + // 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, + height: patch.height, + xoffset: patch.xoffset, + yoffset: patch.yoffset, + }); + + return replaceLayer(glyph, layerId, trimmed); +} + 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/editor/AtlasFloat.tsx b/src/features/editor/AtlasFloat.tsx index 98cbdfd..9cc87ae 100644 --- a/src/features/editor/AtlasFloat.tsx +++ b/src/features/editor/AtlasFloat.tsx @@ -108,7 +108,7 @@ export const AtlasFloat = ({ open, onClose }: Props): React.JSX.Element => { return (
diff --git a/src/features/editor/EditorScreen.tsx b/src/features/editor/EditorScreen.tsx index 344e3eb..2cec8f9 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, trimLayerToInk } from '@/core/project/layers'; import { getGlyphsForProject } from '@/db/glyphs'; import { saveGlyphs } from '@/db/glyphs'; import { ExportDialog } from '@/features/export/ExportDialog'; @@ -20,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'; @@ -114,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]); @@ -247,20 +260,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]); @@ -412,16 +424,24 @@ export const EditorScreen = (): React.JSX.Element => { /> )} -
+
setAtlasOpen((value) => !value)} onPreviewToggle={() => setPreviewOpen((value) => !value)} /> - - setAtlasOpen(false)} /> - setPreviewOpen(false)} /> +
+ {/* The relative wrapper anchors floating panels (AtlasFloat, + PreviewFloat) so their right/bottom positions are measured + against the canvas area, not the side rail. */} +
+ + setAtlasOpen(false)} /> + setPreviewOpen(false)} /> +
+ +
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/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx new file mode 100644 index 0000000..42766a3 --- /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 ( + + ); +}; diff --git a/src/features/editor/pixel-editor/PixelEditor.tsx b/src/features/editor/pixel-editor/PixelEditor.tsx index 9545c50..77c8e44 100644 --- a/src/features/editor/pixel-editor/PixelEditor.tsx +++ b/src/features/editor/pixel-editor/PixelEditor.tsx @@ -17,8 +17,16 @@ import { ZOOM_PRESETS, ZOOM_WHEEL_SENSITIVITY, } from '@/config'; +import { + cloneLayers, + cycleHitLayer, + hitTestLayer, + syncLegacyFields, + unionLayerBounds, + 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'; @@ -53,6 +61,9 @@ 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 multiSelectLayerIds = useStore((state) => state.multiSelectLayerIds); const autoSaveToastTimer = useRef | null>(null); const autoSaveToastShown = useRef(false); @@ -63,17 +74,25 @@ 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, brushSize, 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 { @@ -113,9 +132,31 @@ export const PixelEditor = (): React.JSX.Element => { stateRef.current.brushSize = brushSize; stateRef.current.zoomLevel = zoomLevel; stateRef.current.showGrid = showGrid; + stateRef.current.activeLayerId = activeLayerId; + stateRef.current.multiSelectLayerIds = multiSelectLayerIds; 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; @@ -130,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, @@ -206,31 +249,47 @@ 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. + // Layers being dragged are shifted by (dx, dy); other layers stay at rest. + if (currentGlyph) { const threshold = effectiveThreshold(currentGlyph, project.settings); - 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 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++) { + 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 @@ -278,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) @@ -632,25 +687,51 @@ 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) { - return false; + if (!cell || !currentGlyph || !project) { + return []; } - 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; + if (stateRef.current.multiSelectLayerIds.length > 0) { + return stateRef.current.multiSelectLayerIds; + } + + 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 { + 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 { @@ -661,6 +742,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); @@ -688,10 +775,13 @@ 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; + // 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; @@ -710,8 +800,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; @@ -720,14 +810,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; @@ -755,12 +845,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, }; @@ -785,28 +876,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. - pushUndo(currentGlyph.codePoint, { - pixels: new Uint8Array(currentGlyph.pixels), - width: currentGlyph.width, - height: currentGlyph.height, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }); - stateRef.current.moveOrigin = { - x: event.clientX, - y: event.clientY, - xoffset: currentGlyph.xoffset, - yoffset: currentGlyph.yoffset, - }; + if (targetIds.length > 0 && currentGlyph) { + // Layer drag — undoable. + pushUndo(currentGlyph.codePoint, { layers: cloneLayers(currentGlyph.layers) }); + + 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 { @@ -834,13 +950,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; @@ -862,7 +972,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; @@ -876,15 +986,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; @@ -904,6 +1023,7 @@ export const PixelEditor = (): React.JSX.Element => { function onPointerLeave(): void { stateRef.current.cursorCell = null; + stateRef.current.hoverGrabLayerIds = []; if (stateRef.current.overGrab) { setOverGrab(false); @@ -940,16 +1060,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 (dx !== 0 || dy !== 0) { - const updated: Glyph = { + if (!origin) { + return layer; + } + + 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]); @@ -980,6 +1108,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 }; } 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, 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, 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) => {