From f8e647a348cf680d41ad4c6a7a1d4e4c6d68027a Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 20:00:27 +0300 Subject: [PATCH 1/5] refactor: unify list-row interactions in glyph and layer panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LayerPanel rows now show pointer cursor and a hover background on the whole row, matching the GlyphList affordance. Click anywhere on the row to select; icon buttons stopPropagation so their actions don't double as row selection. - Drop the inner button around the layer name (the row is the click target). - Merge isActive/isMultiSelected into a single isSelected styling check — both states are functionally interchangeable for visual selection. - Unify hover/selected backgrounds across both panels: hover bg-accent/40, selected bg-accent/60. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/features/editor/glyph-list/GlyphList.tsx | 4 +- .../editor/pixel-editor/LayerPanel.tsx | 40 ++++++++++++------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/src/features/editor/glyph-list/GlyphList.tsx b/src/features/editor/glyph-list/GlyphList.tsx index 5e45a68..c9a5394 100644 --- a/src/features/editor/glyph-list/GlyphList.tsx +++ b/src/features/editor/glyph-list/GlyphList.tsx @@ -255,7 +255,7 @@ export const GlyphList = ({ aria-label={label} className={cn( 'flex w-full cursor-pointer items-center px-2 py-1.5 transition-colors', - isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-white/10', + isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40', )} >
@@ -333,7 +333,7 @@ export const GlyphList = ({ aria-selected={isSelected} className={cn( 'flex w-full items-center gap-2 px-2 py-1.5 transition-colors', - isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-white/10', + isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40', )} > @@ -166,31 +171,33 @@ export const LayerPanel = (): React.JSX.Element | null => { size="icon" className="h-6 w-6 shrink-0" title={layer.preview ? 'Render as white (final look)' : 'Render in layer tint'} - onClick={() => handleToggle(layer.id, 'preview')} + onClick={(event) => { + event.stopPropagation(); + handleToggle(layer.id, 'preview'); + }} > - + @@ -200,7 +207,10 @@ export const LayerPanel = (): React.JSX.Element | null => { className="h-6 w-6 shrink-0" title="Delete layer" disabled={glyph.layers.length <= 1} - onClick={() => handleRemoveLayer(layer.id)} + onClick={(event) => { + event.stopPropagation(); + handleRemoveLayer(layer.id); + }} > From 126166b535b2a4e947c14222764076d0b3168f2e Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 20:01:13 +0300 Subject: [PATCH 2/5] refactor: store layer color as palette index and refresh the palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Layer.color (CSS string) with Layer.colorIndex (number), resolved through DEFAULT_LAYER_PALETTE at render time. Reordering layers preserves colorIndex so the user's "the orange layer" mental model stays stable while global palette changes propagate to every existing glyph automatically. - Add layerColor(layer) helper; update LayerPanel and PixelEditor call sites. - DB v2→v3 per-record upgrade strips the old color string and assigns colorIndex = layer position (old defaults always tracked creation index, so this is a 1:1 mapping). - New palette (white, orange, sky blue, yellow, magenta) chosen to stay distinguishable for common color-vision deficiencies; the previous palette clustered green/cyan/blue too tightly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config/layers.ts | 8 ++--- src/core/project/layers.ts | 12 ++++---- src/core/project/types.ts | 4 +-- src/db/db.ts | 30 ++++++++++++++++++- .../editor/pixel-editor/LayerPanel.tsx | 3 +- .../editor/pixel-editor/PixelEditor.tsx | 3 +- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/config/layers.ts b/src/config/layers.ts index 8f70ef2..7283961 100644 --- a/src/config/layers.ts +++ b/src/config/layers.ts @@ -5,8 +5,8 @@ export const MAX_LAYERS_PER_GLYPH = 5; // 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)', + 'oklch(0.80 0.17 50)', + 'oklch(0.78 0.13 230)', + 'oklch(0.88 0.16 95)', + 'oklch(0.72 0.20 330)', ] as const; diff --git a/src/core/project/layers.ts b/src/core/project/layers.ts index 2f98683..3af4499 100644 --- a/src/core/project/layers.ts +++ b/src/core/project/layers.ts @@ -2,13 +2,13 @@ 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]; +export function layerColor(layer: Layer): string { + return DEFAULT_LAYER_PALETTE[layer.colorIndex % DEFAULT_LAYER_PALETTE.length]; } interface MakeBlankLayerOptions { name?: string; - color?: string; + colorIndex?: number; index?: number; } @@ -25,7 +25,7 @@ export function makeBlankLayer(options: MakeBlankLayerOptions = {}): Layer { yoffset: 0, visible: true, preview: true, - color: options.color ?? defaultLayerColor(index), + colorIndex: options.colorIndex ?? index, locked: false, }; } @@ -49,7 +49,7 @@ export function makeBaseLayerFromBitmap(input: MakeBaseLayerFromBitmapInput): La yoffset: input.yoffset, visible: true, preview: true, - color: defaultLayerColor(0), + colorIndex: 0, locked: false, }; } @@ -413,7 +413,7 @@ interface LayerPatch { name?: string; visible?: boolean; preview?: boolean; - color?: string; + colorIndex?: number; locked?: boolean; xoffset?: number; yoffset?: number; diff --git a/src/core/project/types.ts b/src/core/project/types.ts index 239b733..5931966 100644 --- a/src/core/project/types.ts +++ b/src/core/project/types.ts @@ -40,8 +40,8 @@ export interface Layer { 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; + // Editor-only tint slot — resolved through DEFAULT_LAYER_PALETTE at render time. Preserved across reorder. + colorIndex: number; locked: boolean; } diff --git a/src/db/db.ts b/src/db/db.ts index 0bec3d8..b2f1e4d 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,6 +1,6 @@ import Dexie, { type EntityTable } from 'dexie'; -import type { Glyph, Project } from '@/core/project'; +import type { Glyph, Layer, Project } from '@/core/project'; import { makeBaseLayerFromBitmap } from '@/core/project/layers'; interface FontFile { @@ -29,6 +29,21 @@ export function upgradeGlyphV1ToV2(record: Glyph & { id: string }): void { ]; } +/** + * v2→v3 per-record upgrade: drop the stored `color` string from each layer and + * replace it with `colorIndex` derived from the layer's position. Old defaults + * always tracked the creation index, so position is an exact equivalent. + */ +export function upgradeGlyphV2ToV3(record: Glyph & { id: string }): void { + record.layers = record.layers.map((layer, index) => { + const next = { ...layer, colorIndex: index }; + + delete (next as Layer & { color?: string }).color; + + return next; + }); +} + class BmfDatabase extends Dexie { projects!: EntityTable; glyphs!: EntityTable; @@ -56,6 +71,19 @@ class BmfDatabase extends Dexie { .toCollection() .modify((record) => upgradeGlyphV1ToV2(record)), ); + + this.version(3) + .stores({ + projects: 'id, updatedAt', + glyphs: '[projectId+codePoint], projectId, id', + fontFiles: 'id', + }) + .upgrade((transaction) => + transaction + .table('glyphs') + .toCollection() + .modify((record) => upgradeGlyphV2ToV3(record)), + ); } } diff --git a/src/features/editor/pixel-editor/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx index 50a3973..56ac778 100644 --- a/src/features/editor/pixel-editor/LayerPanel.tsx +++ b/src/features/editor/pixel-editor/LayerPanel.tsx @@ -6,6 +6,7 @@ import { MAX_LAYERS_PER_GLYPH } from '@/config'; import { addLayer, cloneLayers, + layerColor, removeLayer, updateLayer, } from '@/core/project/layers'; @@ -178,7 +179,7 @@ export const LayerPanel = (): React.JSX.Element | null => { > { 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)'; + const inkColor = layer.preview ? layerColor(layer) : 'rgb(255,255,255)'; for (let row = 0; row < layer.height; row++) { for (let column = 0; column < layer.width; column++) { From e1205920d2df02c34a0f4bf3224d3097c0552d6a Mon Sep 17 00:00:00 2001 From: Antti Urpelainen Date: Tue, 16 Jun 2026 20:05:01 +0300 Subject: [PATCH 3/5] feat: drag-and-drop layer reordering in LayerPanel Whole-row HTML5 drag-and-drop wired to the existing reorderLayers core fn. Dragging shifts the source row's opacity and shows a primary-colored insertion line at the top or bottom edge of the target row depending on pointer position. Click-to-select still works (DnD only kicks in past the browser's drag threshold). The panel renders the reversed layers array (top of panel = topmost layer = last in array), so handleDrop converts panel-space "above/below" to the corresponding array indices before calling reorderLayers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../editor/pixel-editor/LayerPanel.tsx | 93 ++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/src/features/editor/pixel-editor/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx index 56ac778..feb9758 100644 --- a/src/features/editor/pixel-editor/LayerPanel.tsx +++ b/src/features/editor/pixel-editor/LayerPanel.tsx @@ -1,5 +1,5 @@ import { Eye, EyeOff, Lock, Palette, Plus, Trash2, Unlock } from 'lucide-react'; -import React from 'react'; +import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; import { MAX_LAYERS_PER_GLYPH } from '@/config'; @@ -8,6 +8,7 @@ import { cloneLayers, layerColor, removeLayer, + reorderLayers, updateLayer, } from '@/core/project/layers'; import { saveGlyphs } from '@/db/glyphs'; @@ -24,6 +25,11 @@ export const LayerPanel = (): React.JSX.Element | null => { const upsertGlyph = useStore((state) => state.upsertGlyph); const pushUndo = useStore((state) => state.pushUndo); + const [dragSourceId, setDragSourceId] = useState(null); + const [dropIndicator, setDropIndicator] = useState< + { targetId: string; position: 'above' | 'below' } | null + >(null); + const glyph = glyphs.find((glyphItem) => glyphItem.codePoint === selectedCodePoint); if (!glyph) { @@ -117,6 +123,40 @@ export const LayerPanel = (): React.JSX.Element | null => { } } + function handleDrop(targetId: string, position: 'above' | 'below'): void { + if (!glyph || !dragSourceId || dragSourceId === targetId) { + return; + } + + const fromIndex = glyph.layers.findIndex((layer) => layer.id === dragSourceId); + const targetIndex = glyph.layers.findIndex((layer) => layer.id === targetId); + + if (fromIndex === -1 || targetIndex === -1) { + return; + } + + // Panel is rendered top-to-bottom over the reversed layers array, so + // "above the target row" means a higher array index, "below" means lower. + let toIndex = position === 'above' ? targetIndex + 1 : targetIndex; + + if (fromIndex < toIndex) { + // After splicing the source out, indices above it shift down by one. + toIndex -= 1; + } + + if (fromIndex === toIndex) { + return; + } + + const next = reorderLayers(glyph, fromIndex, toIndex); + + if (next === glyph) { + return; + } + + commit({ ...next, isDirty: true }); + } + // 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; @@ -143,17 +183,68 @@ export const LayerPanel = (): React.JSX.Element | null => { {renderedLayers.map((layer) => { const isSelected = layer.id === activeLayerId || multiSelectLayerIds.includes(layer.id); + const isDragging = dragSourceId === layer.id; + const isDropAbove = + dropIndicator?.targetId === layer.id && dropIndicator.position === 'above'; + const isDropBelow = + dropIndicator?.targetId === layer.id && dropIndicator.position === 'below'; return (
  • handleSelectLayer(layer.id, event.shiftKey || event.metaKey || event.ctrlKey) } + onDragStart={(event) => { + event.dataTransfer.effectAllowed = 'move'; + setDragSourceId(layer.id); + }} + onDragOver={(event) => { + if (!dragSourceId || dragSourceId === layer.id) { + return; + } + + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + + const rect = event.currentTarget.getBoundingClientRect(); + const position: 'above' | 'below' = + event.clientY < rect.top + rect.height / 2 ? 'above' : 'below'; + + if ( + dropIndicator?.targetId !== layer.id || + dropIndicator.position !== position + ) { + setDropIndicator({ targetId: layer.id, position }); + } + }} + onDragLeave={() => { + if (dropIndicator?.targetId === layer.id) { + setDropIndicator(null); + } + }} + onDrop={(event) => { + event.preventDefault(); + + if (dropIndicator) { + handleDrop(dropIndicator.targetId, dropIndicator.position); + } + + setDragSourceId(null); + setDropIndicator(null); + }} + onDragEnd={() => { + setDragSourceId(null); + setDropIndicator(null); + }} >
  • - + setLayerPanelCollapsed(!layerPanelCollapsed)} + /> diff --git a/src/features/editor/pixel-editor/LayerPanel.tsx b/src/features/editor/pixel-editor/LayerPanel.tsx index feb9758..e251930 100644 --- a/src/features/editor/pixel-editor/LayerPanel.tsx +++ b/src/features/editor/pixel-editor/LayerPanel.tsx @@ -1,4 +1,14 @@ -import { Eye, EyeOff, Lock, Palette, Plus, Trash2, Unlock } from 'lucide-react'; +import { + ChevronLeft, + ChevronRight, + Eye, + EyeOff, + Lock, + Palette, + Plus, + Trash2, + Unlock, +} from 'lucide-react'; import React, { useState } from 'react'; import { Button } from '@/components/ui/button'; @@ -15,7 +25,15 @@ import { saveGlyphs } from '@/db/glyphs'; import { cn } from '@/lib/utils'; import { useStore } from '@/store'; -export const LayerPanel = (): React.JSX.Element | null => { +interface LayerPanelProps { + collapsed: boolean; + onCollapse: () => void; +} + +export const LayerPanel = ({ + collapsed, + onCollapse, +}: LayerPanelProps): React.JSX.Element | null => { const glyphs = useStore((state) => state.glyphs); const selectedCodePoint = useStore((state) => state.selectedCodePoint); const activeLayerId = useStore((state) => state.activeLayerId); @@ -164,19 +182,48 @@ export const LayerPanel = (): React.JSX.Element | null => { return (