Layer, frame and shape lists all reorder by drag and drop

Reordering shapes by dragging worked well in the vector tab. Pixel
art layers and frames and tilemap layers still used up/down buttons.
All three lists now share one widget built from the shape list.

- SortableList in the UI toolkit, with the drag rules next to it in
  src/ui/sortable.ts. A row has an optional thumbnail, a name, a hint
  on the right and an eye toggle. Every drop is one undo step.
- The shape list is a thin wrapper around it. shapeRows.ts is gone,
  its tests moved to the new rules.
- Pixel art layers show the layer alone on the current frame, frames
  show the whole frame. Up/Down, Hide, Earlier and Later are gone.
- Tilemap layers get the eye instead of the checkbox. The per-row
  up, down and delete buttons are replaced by Delete layer next to
  Add layer.
- A row is selected on release instead of press. Selecting rebuilt
  the inspector or tilemap panel and cancelled a drag that started
  on a row not yet selected.

Tests:
- ./check.sh: 138 files, 2052 tests, exit 0 (before 137 / 2051).
- GM_STEPS=39,40,47,48 (fresh seed): ALL PASSED, 154 checks.
- 5 new e2e checks. Check total 1264 in the README is computed
  (1259 + 5), CI never measured 1259.
- Counter-checks bite: dropMove ignoring reversed fails a unit test,
  a tilemap drop not applied fails the drag check, a layer thumbnail
  showing all layers fails the thumbnail check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-21 18:48:50 +02:00
1 parent 8adef83f23
commit 5b9b57fd19
14 files changed
+699 -326

No files matched your search

+32 -16
View File
@@ -528,16 +528,34 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
side.reveal(side.palette);
return side.palette.swatchGlobal(index);
},
/**
* The centre of a layer's or frame's row, scrolled into view, with the centre of its picture;
* `eye` is the layer's switch.
*/
layerRow: (layerId: string) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.layers);
return side.layers.rowGlobal(layerId);
const row = side.layers.rowGlobal(layerId);
return row
? {
x: row.x + row.w / 2,
y: row.y + row.h / 2,
thumb: side.layers.thumbGlobal(layerId),
eye: side.layers.eyeGlobal(layerId),
}
: null;
},
frameRow: (frameId: string) => {
const side = editor.pixelArtEditor.side;
side.reveal(side.frames);
return side.frames.rowGlobal(frameId);
const row = side.frames.rowGlobal(frameId);
return row
? { x: row.x + row.w / 2, y: row.y + row.h / 2, thumb: side.frames.thumbGlobal(frameId) }
: null;
},
/** The ids as the two lists show them, top first. */
layerRows: () => editor.pixelArtEditor.side.layers.shownIds,
frameRows: () => editor.pixelArtEditor.side.frames.shownIds,
previewIndex: () => editor.pixelArtEditor.side.preview.shownIndex,
/** Canvas, layers and frames as the model holds them. */
asset: (assetId: string) => {
@@ -767,21 +785,19 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
const button = currentTilemapWidgets()?.focus;
return button ? { ...centreOf(button), label: button.text } : null;
},
/** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */
/** One row of the layer list, top-down as drawn: the layer, where to press it, and its eye. */
layerRowAt: (index: number) => {
const row = currentTilemapWidgets()?.layers[index];
return row
? {
id: row.id,
// `y` is the row's own line, so a check can tell whether it is inside the panel.
y: centreOf(row.name).y,
name: centreOf(row.name),
visible: centreOf(row.visible),
up: centreOf(row.up),
down: centreOf(row.down),
remove: centreOf(row.remove),
}
: null;
const list = currentTilemapWidgets()?.layers;
const id = list?.shownIds[index];
const row = id ? list.rowGlobal(id) : null;
if (!list || !id || !row) return null;
const name = { x: row.x + row.w / 2, y: row.y + row.h / 2 };
// `y` is the row's own line, so a check can tell whether it is inside the panel.
return { id, y: name.y, name, visible: list.eyeGlobal(id) };
},
removeLayerAt: () => {
const button = currentTilemapWidgets()?.removeLayer;
return button ? { ...centreOf(button), enabled: button.enabled } : null;
},
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
+28 -30
View File
@@ -15,7 +15,6 @@ import {
SetTilemapLayerHiddenCommand,
SetTilemapLayerZCommand,
} from '../../core/commands/tilemapCommands';
import type { Command } from '../../core/commands/Command';
import type { TilemapAsset } from '../../core/model/Asset';
import { findObject } from '../../core/model/hierarchy';
import { resolvedHolder } from '../../core/model/instance';
@@ -23,13 +22,13 @@ import { findAssetOf } from '../../core/model/Project';
import { findLayer, isLayerVisible, layerZ } from '../../core/model/tilemap';
import {
Button,
Checkbox,
Dropdown,
HStack,
Menu,
NumberField,
Panel,
ScrollContainer,
SortableList,
TextField,
Theme,
ui,
@@ -134,7 +133,8 @@ export interface TilemapPanelWidgets {
tools: Map<TilemapTool, Button>;
/** Null while the project has no tile. */
palette: TilePalette | null;
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
layers: SortableList;
removeLayer: Button;
/** The name of the layer painted on (#138). */
layerName: TextField;
/** Its `z` above the map's own (#174). */
@@ -287,7 +287,18 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
const widgets: TilemapPanelWidgets = {
tools: new Map(),
palette: null,
layers: [],
layers: new SortableList({
reversed: true,
onPick: (layerId) => state.setTilemapPaint({ layerId }),
onMove: (from, to) => state.execute(new MoveTilemapLayerCommand(asset.id, from, to)),
onToggleHidden: (layerId) =>
state.execute(
new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)),
),
}),
removeLayer: new Button('Delete layer', {
onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, painted)),
}),
layerName,
layerZ: layerZField,
focus,
@@ -329,31 +340,14 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
addNote(host, 'Choose a tile to paint with.', 'small');
addTitle(host, 'LAYERS');
addNote(host, 'The last layer is drawn on top.', 'small');
// Bottom-up on screen, as in every layer list: the topmost row is the one drawn last.
for (let index = asset.layers.length - 1; index >= 0; index--) {
const layer = asset.layers[index]!;
const row = new HStack({ gap: Theme.gap });
const visible = new Checkbox(isLayerVisible(layer));
visible.onChange = (checked) =>
state.execute(new SetTilemapLayerHiddenCommand(asset.id, layer.id, !checked));
row.add(visible);
const choose = new Button(layer.name, {
align: 'left',
onClick: () => state.setTilemapPaint({ layerId: layer.id }),
});
choose.toggled = layer.id === painted;
row.add(choose, 1);
const small = (label: string, command: () => Command) =>
row.add(new Button(label, { variant: 'ghost', width: 24, onClick: () => state.execute(command()) }));
const up = small('↑', () => new MoveTilemapLayerCommand(asset.id, index, index + 1));
const down = small('↓', () => new MoveTilemapLayerCommand(asset.id, index, index - 1));
const remove = small('✕', () => new RemoveTilemapLayerCommand(asset.id, layer.id));
widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove });
host.body.add(row);
}
// Below the list rather than in it: a row is a button that picks the layer, and a text field
// in its place would take the click that chooses it.
widgets.layers.setItems(
asset.layers.map((layer) => ({ id: layer.id, label: layer.name, hidden: !isLayerVisible(layer) })),
[painted],
);
host.body.add(widgets.layers);
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
// Below the list rather than in it: a press on a row picks the layer, and a text field in its
// place would take the click that chooses it.
const nameRow = labelledRow('Name').row;
nameRow.add(layerName, 1);
host.body.add(nameRow);
@@ -366,7 +360,11 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
host.body.add(showRow);
addNote(host, 'Show only changes the scene view, not the game.', 'small');
addGap(host);
host.body.add(widgets.addLayer);
widgets.removeLayer.setEnabled(asset.layers.length > 1);
const layerButtons = new HStack({ gap: Theme.gap });
layerButtons.add(widgets.addLayer, 1);
layerButtons.add(widgets.removeLayer, 1);
host.body.add(layerButtons);
}
function tileIdsOf(state: EditorState): string[] {
+58 -39
View File
@@ -6,6 +6,7 @@
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { oneStep } from '../../core/commands/CompoundCommand';
import { nextAssetName } from '../../core/commands/naming';
import type { Graphics } from 'pixi.js';
import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset';
import { colorAt, frameLabel, PALETTE_KEY, paletteAssetOf, paletteColors } from '../../core/model/pixelart';
import { BRUSH_MAX, BRUSH_MIN } from '../../core/model/pixelDraw';
@@ -16,16 +17,17 @@ import {
Dropdown,
HStack,
Label,
ListView,
Menu,
NumberField,
ScrollContainer,
SortableList,
Spacer,
TextField,
Theme,
VStack,
type Widget,
} from '../../ui';
import { pixelCels } from '../../viewport/PixelCels';
import type { EditorState } from '../state';
import { PaletteGrid } from './PaletteGrid';
import { type PixelArtCanvas, PIXEL_TOOL_LABELS, PIXEL_TOOLS, type PixelTool } from './PixelArtCanvas';
@@ -45,10 +47,10 @@ import {
setPaletteColor,
} from './pixelActions';
import { PixelPreview } from './PixelPreview';
import { PixelThumbs } from './PixelThumbs';
/** Four tools to a row, so the column stays narrow enough for the canvas beside it. */
const TOOLS_PER_ROW = 4;
const LIST_ROWS = 4;
function brushLabel(size: number): string {
return `Brush: ${size} px`;
@@ -67,12 +69,14 @@ export class PixelArtSide extends ScrollContainer {
readonly removeColorButton = new Button('Remove last');
readonly createPaletteButton = new Button('Create palette');
private readonly paletteNote = new Label('', { style: 'muted' });
readonly layers = new ListView();
readonly layers: SortableList;
readonly layerName = new TextField('');
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
readonly layerButtons: Record<'add' | 'remove' | 'up' | 'down' | 'merge' | 'hide', Button>;
readonly frames = new ListView();
readonly frameButtons: Record<'add' | 'duplicate' | 'remove' | 'earlier' | 'later', Button>;
readonly layerButtons: Record<'add' | 'remove' | 'merge', Button>;
readonly frames: SortableList;
readonly frameButtons: Record<'add' | 'duplicate' | 'remove', Button>;
private readonly thumbs = new PixelThumbs();
private readonly stopCels: () => void;
readonly onionBox = new Checkbox(false);
readonly tilesBox = new Checkbox(false);
readonly preview: PixelPreview;
@@ -125,8 +129,14 @@ export class PixelArtSide extends ScrollContainer {
c.add(row(this.paletteNote, new Spacer(), this.createPaletteButton));
c.add(heading('Layers'));
this.layers.minHeight = LIST_ROWS * this.layers.rowHeight;
this.layers.onSelect = (id) => id && this.canvas.selectLayer(id);
this.layers = new SortableList({
reversed: true,
onPick: (id) => this.canvas.selectLayer(id),
onMove: (from, to) =>
this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)),
onToggleHidden: (id) => this.toggleHidden(id),
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)),
});
c.add(this.layers);
this.layerName.onChange = (name) =>
this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name));
@@ -139,24 +149,20 @@ export class PixelArtSide extends ScrollContainer {
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeLayer(state, a, this.canvas.layerId)),
}),
up: new Button('Up', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, 1)),
}),
down: new Button('Down', {
onClick: () => this.withAsset((a) => moveLayer(state, a, this.canvas.layerId, -1)),
}),
merge: new Button('Merge down', {
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
}),
hide: new Button('Hide', { minWidth: 56, onClick: () => this.toggleHidden() }),
};
const lb = this.layerButtons;
c.add(row(lb.add, lb.remove, lb.up, lb.down));
c.add(row(lb.merge, lb.hide));
c.add(row(lb.add, lb.remove, lb.merge));
c.add(heading('Frames'));
this.frames.minHeight = LIST_ROWS * this.frames.rowHeight;
this.frames.onSelect = (id) => id && this.canvas.selectFrame(id);
this.frames = new SortableList({
onPick: (id) => this.canvas.selectFrame(id),
onMove: (from, to) =>
this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)),
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)),
});
c.add(this.frames);
this.frameButtons = {
add: new Button('Add', { onClick: () => this.addFrame(false) }),
@@ -164,16 +170,9 @@ export class PixelArtSide extends ScrollContainer {
remove: new Button('Delete', {
onClick: () => this.withAsset((a) => removeFrame(state, a, this.canvas.frameId)),
}),
earlier: new Button('Earlier', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, -1)),
}),
later: new Button('Later', {
onClick: () => this.withAsset((a) => moveFrame(state, a, this.canvas.frameId, 1)),
}),
};
const fb = this.frameButtons;
c.add(row(fb.add, fb.duplicate, fb.remove));
c.add(row(fb.earlier, fb.later));
c.add(heading('View'));
this.onionBox.onChange = (on) => {
@@ -197,6 +196,17 @@ export class PixelArtSide extends ScrollContainer {
this.setContent(this.column);
this.setTool('pencil');
state.on('project', () => this.refresh());
// A cel that lands after the list was drawn shows in its picture.
this.stopCels = pixelCels.onChange(() => {
this.layers.invalidate();
this.frames.invalidate();
});
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
this.stopCels();
this.thumbs.destroy();
super.destroy(options);
}
/** Scrolls the column until `widget` is in view – the check scripts reveal a control before they click it. */
@@ -290,12 +300,24 @@ export class PixelArtSide extends ScrollContainer {
if (id) this.canvas.selectLayer(id);
}
private toggleHidden(): void {
private toggleHidden(layerId: string): void {
const asset = this.canvas.asset();
const layer = asset?.layers.find((l) => l.id === this.canvas.layerId);
const layer = asset?.layers.find((l) => l.id === layerId);
if (asset && layer) setLayerHidden(this.state, asset, layer.id, !layer.hidden);
}
/** A layer alone on the frame being painted, or with `frameId` that whole frame. */
private paintThumb(
asset: PixelArtAsset,
art: Graphics,
size: number,
layerId?: string,
frameId = this.canvas.frameId,
): void {
const frame = asset.frames.find((f) => f.id === frameId);
if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId);
}
private addFrame(copy: boolean): void {
const asset = this.canvas.asset();
if (!asset) return;
@@ -323,13 +345,14 @@ export class PixelArtSide extends ScrollContainer {
const layerId = this.canvas.layerId;
this.layers.setItems(
[...(asset?.layers ?? [])].reverse().map((layer) => ({
(asset?.layers ?? []).map((layer) => ({
id: layer.id,
label: layer.hidden ? `${layer.name} (hidden)` : layer.name,
label: layer.name,
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
hidden: layer.hidden,
})),
layerId ? [layerId] : [],
);
this.layers.select(layerId || null, true);
const layer = asset?.layers.find((l) => l.id === layerId);
this.layerName.setValue(layer?.name ?? '');
this.layerOpacity.setNumber(layer?.opacity ?? 1);
@@ -337,22 +360,18 @@ export class PixelArtSide extends ScrollContainer {
const lb = this.layerButtons;
lb.add.setEnabled(asset !== null);
lb.remove.setEnabled((asset?.layers.length ?? 0) > 1);
lb.up.setEnabled(asset !== null && index >= 0 && index < asset.layers.length - 1);
lb.down.setEnabled(index > 0);
lb.merge.setEnabled(index > 0);
lb.hide.setText(layer?.hidden ? 'Show' : 'Hide');
lb.hide.setEnabled(layer !== undefined);
const frameId = this.canvas.frameId;
this.frames.setItems((asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })));
this.frames.select(frameId || null, true);
this.frames.setItems(
(asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })),
frameId ? [frameId] : [],
);
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
const fb = this.frameButtons;
fb.add.setEnabled(asset !== null);
fb.duplicate.setEnabled(frameIndex >= 0);
fb.remove.setEnabled((asset?.frames.length ?? 0) > 1);
fb.earlier.setEnabled(frameIndex > 0);
fb.later.setEnabled(asset !== null && frameIndex >= 0 && frameIndex < asset.frames.length - 1);
this.relayoutContent();
}
}
+83
View File
@@ -0,0 +1,83 @@
/**
* The little pictures in the layer and frame lists of the pixel art tab: a layer alone on the frame
* being painted, or a whole frame. Each row keeps one canvas that is painted over when what it shows
* changes – the key says when – so a stroke recomposes only the rows it touched.
*/
import { CanvasSource, type Graphics, Texture } from 'pixi.js';
import type { PixelArtAsset, PixelFrame } from '../../core/model/Asset';
import { composeFrame } from '../../core/model/pixelart';
import { pixelCels } from '../../viewport/PixelCels';
import { drawChecker } from '../texture/checker';
interface Slot {
key: string;
canvas: HTMLCanvasElement;
texture: Texture;
}
export class PixelThumbs {
private readonly slots = new Map<string, Slot>();
/** Textures of canvases replaced by a bigger or smaller one: a list may still draw with them. */
private readonly retired: Texture[] = [];
/** Paints `frame` into `art`, fitted into `size`; `layerId` narrows it to that one layer. */
paint(
art: Graphics,
size: number,
asset: PixelArtAsset,
colors: readonly number[],
frame: PixelFrame,
layerId?: string,
): void {
const scale = Math.min(size / asset.width, size / asset.height);
const w = asset.width * scale;
const h = asset.height * scale;
const x = (size - w) / 2;
const y = (size - h) / 2;
drawChecker(art, x, y, w, h, size, size);
const texture = this.texture(asset, colors, frame, layerId);
if (texture) art.rect(x, y, w, h).fill({ texture });
}
destroy(): void {
for (const slot of this.slots.values()) slot.texture.destroy(true);
for (const texture of this.retired) texture.destroy(true);
this.slots.clear();
this.retired.length = 0;
}
private texture(
asset: PixelArtAsset,
colors: readonly number[],
frame: PixelFrame,
layerId: string | undefined,
): Texture | null {
// A layer is shown whole even while hidden or faded: the row says that with its eye.
const layers = layerId
? asset.layers.filter((l) => l.id === layerId).map((l) => ({ ...l, hidden: false, opacity: 1 }))
: asset.layers;
const cels = layers.map((l) => `${frame.cels[l.id] ?? ''}:${l.hidden ? 'h' : l.opacity}`).join(',');
const key = `${asset.width}x${asset.height}|${colors.join(',')}|${cels}`;
const id = `${asset.id}/${frame.id}/${layerId ?? ''}`;
let slot = this.slots.get(id);
if (slot?.key === key) return slot.texture;
const rgba = composeFrame({ ...asset, layers }, colors, frame, (file) => pixelCels.read(file));
if (!rgba) return slot?.texture ?? null;
if (!slot || slot.canvas.width !== asset.width || slot.canvas.height !== asset.height) {
if (slot) this.retired.push(slot.texture);
const canvas = document.createElement('canvas');
canvas.width = asset.width;
canvas.height = asset.height;
slot = {
key,
canvas,
texture: new Texture({ source: new CanvasSource({ resource: canvas, scaleMode: 'nearest' }) }),
};
this.slots.set(id, slot);
}
slot.key = key;
slot.canvas.getContext('2d')?.putImageData(new ImageData(rgba, asset.width, asset.height), 0, 0);
slot.texture.source.update();
return slot.texture;
}
}
+5 -11
View File
@@ -85,12 +85,9 @@ export function setLayerOpacity(
return edit(state, asset, 'Layer opacity', (s) => withLayerChanged(s, layerId, { opacity }));
}
/** Up is towards the top of the drawing order, the end of the list. */
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, step: 1 | -1): boolean {
const index = asset.layers.findIndex((layer) => layer.id === layerId);
return edit(state, asset, step > 0 ? 'Move layer up' : 'Move layer down', (s) =>
withLayerMoved(s, layerId, index + step),
);
/** `to` is the layer's new index in the drawing order, whose end is the top. */
export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: string, to: number): boolean {
return edit(state, asset, 'Reorder layers', (s) => withLayerMoved(s, layerId, to));
}
/** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */
@@ -129,11 +126,8 @@ export function removeFrame(state: EditorState, asset: PixelArtAsset, frameId: s
return edit(state, asset, 'Delete frame', (s) => withFrameRemoved(s, frameId));
}
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, step: 1 | -1): boolean {
const index = asset.frames.findIndex((frame) => frame.id === frameId);
return edit(state, asset, step > 0 ? 'Move frame later' : 'Move frame earlier', (s) =>
withFrameMoved(s, frameId, index + step),
);
export function moveFrame(state: EditorState, asset: PixelArtAsset, frameId: string, to: number): boolean {
return edit(state, asset, 'Reorder frames', (s) => withFrameMoved(s, frameId, to));
}
/** Every cel moved onto a canvas of the new size (answer 3), in one undo step. */
+42 -182
View File
@@ -2,57 +2,25 @@
* The shapes of a vector art asset as a list with a preview each (#129, stage 4). It stands in the
* inspector, over the rows that colour the selected shape – a panel of its own is #191.
*
* Two things make it more than a column of buttons. Each row draws the shape itself, with the same
* commands the rasteriser uses, placed on the canvas it sits on: two shapes of the same colour are
* told apart by where they are, which a name rarely says. And the list is the drawing order, so a
* row may be dragged to another place in it – the topmost row is the shape drawn last, as in every
* layer list.
* Each row draws the shape itself, with the same commands the rasteriser uses, placed on the canvas
* it sits on: two shapes of the same colour are told apart by where they are, which a name rarely
* says. The list is the drawing order, top row drawn last, and a row is dragged to reorder it.
*
* The rows are fixed when the list is built: the inspector section rebuilds whenever a shape is
* added, removed or renamed. A shape merely edited repaints its preview, which is why the shapes
* themselves are read out of the model on every redraw instead of being kept here.
*/
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
import type { VectorArtAsset, VectorShape } from '../../core/model/Asset';
import type { Graphics } from 'pixi.js';
import type { VectorArtAsset } from '../../core/model/Asset';
import { findAssetOf } from '../../core/model/Project';
import { vectorCanvasSize } from '../../core/model/vectorart';
import { shapeDrawing } from '../../core/model/vectorPath';
import { textureLibrary } from '../../viewport/TextureLibrary';
import { PaintCache, paintDrawing } from '../../viewport/vectorArtPicture';
import { type Size, Theme, textStyle, truncate, Widget } from '../../ui';
import { SortableList, type ThumbClip, type Widget } from '../../ui';
import type { EditorState } from '../state';
import { shapeDropGap, shapeDropMove, shapeRowOrder } from './shapeRows';
/** Height of one row: the swatch plus the air a hovered row needs to look like a row. */
export const SHAPE_ROW_HEIGHT = 30;
const SWATCH = 22;
const PAD = 4;
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
const DRAG_THRESHOLD = 4;
interface Row {
box: Container;
art: Graphics;
clip: Graphics;
label: Text;
}
interface RowDrag {
/** Index of the pressed row in display order. */
from: number;
startY: number;
y: number;
active: boolean;
}
export class ShapeList extends Widget {
private readonly background = new Graphics();
private readonly marker = new Graphics();
private readonly rows: Row[] = [];
/** Display order, top first – the reverse of the model, whose last shape lies on top. */
private readonly shown: readonly string[];
private hoveredRow = -1;
private drag: RowDrag | null = null;
export class ShapeList extends SortableList {
private readonly paints = new PaintCache();
private readonly stop: () => void;
private readonly stopPictures: () => void;
@@ -65,37 +33,26 @@ export class ShapeList extends Widget {
private readonly state: EditorState,
private readonly assetId: string,
shapeIds: readonly string[],
private readonly selectedIds: readonly string[],
private readonly onPick: (shapeId: string, toggle: boolean) => void,
private readonly onReorder: (from: number, to: number) => void,
selectedIds: readonly string[],
onPick: (shapeId: string, toggle: boolean) => void,
onReorder: (from: number, to: number) => void,
) {
super();
this.shown = shapeRowOrder(shapeIds);
this.cursor = Theme.hoverCursor;
this.addChild(this.background);
for (const _ of this.shown) this.rows.push(this.buildRow());
this.marker.eventMode = 'none';
this.addChild(this.marker);
super({
reversed: true,
multiSelect: true,
onPick,
onMove: onReorder,
thumb: (id, art, size) => this.paintSwatch(id, art, size),
});
const asset = this.asset();
this.setItems(
shapeIds.map((id) => ({ id, label: asset?.shapes.find((s) => s.id === id)?.name ?? '' })),
selectedIds,
);
// A point dragged in the canvas changes no row, only what a row shows.
this.stop = state.on('project', () => this.invalidate());
// A shape filled with a picture (#186) shows its colour until the picture has landed.
this.stopPictures = textureLibrary.onChange(() => this.invalidate());
this.on('pointermove', (e: FederatedPointerEvent) => {
const index = this.rowAt(this.toLocal(e.global).y);
if (index === this.hoveredRow) return;
this.hoveredRow = index;
this.invalidate();
});
this.on('pointerout', () => {
if (this.hoveredRow < 0) return;
this.hoveredRow = -1;
this.invalidate();
});
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPress(e));
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onMove(e));
this.on('pointerup', () => this.onRelease());
this.on('pointerupoutside', () => this.onRelease());
}
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
@@ -105,134 +62,37 @@ export class ShapeList extends Widget {
super.destroy(options);
}
override measure(): Size {
return { w: this.minWidth, h: Math.max(SHAPE_ROW_HEIGHT, this.shown.length * SHAPE_ROW_HEIGHT) };
}
/** Where a shape's row sits on screen (test hook); null when the list does not show it. */
rowGlobal(shapeId: string): { x: number; y: number; w: number; h: number } | null {
const index = this.shown.indexOf(shapeId);
if (index < 0) return null;
const origin = this.getGlobalPosition();
return { x: origin.x, y: origin.y + index * SHAPE_ROW_HEIGHT, w: this.w, h: SHAPE_ROW_HEIGHT };
}
/** The shapes as the list shows them, top first – for the check scripts. */
get shownIds(): readonly string[] {
return this.shown;
}
private buildRow(): Row {
const box = new Container();
const art = new Graphics();
const clip = new Graphics();
const label = new Text({ text: '', style: textStyle('body') });
box.eventMode = 'none';
box.addChild(art, clip, label);
art.mask = clip;
this.addChild(box);
return { box, art, clip, label };
override redraw(): void {
super.redraw();
this.paints.sweep();
}
private asset(): VectorArtAsset | null {
return findAssetOf(this.state.project, this.assetId, 'vectorart') ?? null;
}
private rowAt(y: number): number {
const index = Math.floor(y / SHAPE_ROW_HEIGHT);
return index >= 0 && index < this.shown.length ? index : -1;
}
private dropGap(y: number): number {
return shapeDropGap(this.shown.length, y, SHAPE_ROW_HEIGHT);
}
private onPress(e: FederatedPointerEvent): void {
if (e.button !== 0) return;
const y = this.toLocal(e.global).y;
const index = this.rowAt(y);
if (index < 0) return;
e.stopPropagation();
// Picked right away rather than on release: the rows below show what the selected shape is.
const shapeId = this.shown[index]!;
if (e.ctrlKey || e.metaKey || e.shiftKey) {
this.onPick(shapeId, true);
return;
}
if (this.selectedIds.length !== 1 || this.selectedIds[0] !== shapeId) this.onPick(shapeId, false);
this.drag = { from: index, startY: y, y, active: false };
}
private onMove(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
drag.y = this.toLocal(e.global).y;
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return;
drag.active = true;
this.invalidate();
}
private onRelease(): void {
const drag = this.drag;
this.drag = null;
if (!drag) return;
this.invalidate();
if (!drag.active) return;
const move = shapeDropMove(this.shown.length, drag.from, this.dropGap(drag.y));
if (move) this.onReorder(move.from, move.to);
}
override redraw(): void {
const asset = this.asset();
this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control);
this.marker.clear();
for (const [index, shapeId] of this.shown.entries()) {
const row = this.rows[index]!;
const shape = asset?.shapes.find((s) => s.id === shapeId);
const y = index * SHAPE_ROW_HEIGHT;
row.box.position.set(0, y);
row.box.visible = Boolean(shape);
if (!shape) continue;
const selected = this.selectedIds.includes(shapeId);
if (selected || index === this.hoveredRow)
this.background
.rect(0, y, this.w, SHAPE_ROW_HEIGHT)
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
this.paintSwatch(row, asset!, shape);
row.label.style = textStyle(selected ? 'body' : 'muted');
row.label.text = truncate(shape.name, row.label.style, Math.max(0, this.w - SWATCH - PAD * 3));
row.label.position.set(PAD * 2 + SWATCH, (SHAPE_ROW_HEIGHT - row.label.height) / 2);
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
}
this.paints.sweep();
if (this.drag?.active) {
const y = this.dropGap(this.drag.y) * SHAPE_ROW_HEIGHT;
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
}
}
/**
* The shape alone, on the canvas it sits on: the whole canvas is scaled into the swatch, so a
* shape in the corner is drawn in the corner. What lies outside the canvas is cut off here as it
* is in the picture – the mask is the swatch, the canvas being scaled onto exactly that box.
* is in the picture.
*/
private paintSwatch(row: Row, asset: VectorArtAsset, shape: VectorShape): void {
private paintSwatch(shapeId: string, art: Graphics, size: number): ThumbClip | void {
const asset = this.asset();
const shape = asset?.shapes.find((s) => s.id === shapeId);
if (!asset || !shape) return;
const { width, height } = vectorCanvasSize(asset.props);
const scale = Math.min(SWATCH / width, SWATCH / height);
const x = PAD + (SWATCH - width * scale) / 2;
const y = (SHAPE_ROW_HEIGHT - height * scale) / 2;
row.clip
.clear()
.rect(x, y, width * scale, height * scale)
.fill(0xffffff);
row.art.clear();
row.art.position.set(x, y);
row.art.scale.set(scale);
const scale = Math.min(size / width, size / height);
const clip = {
x: (size - width * scale) / 2,
y: (size - height * scale) / 2,
w: width * scale,
h: height * scale,
};
art.position.x += clip.x;
art.position.y += clip.y;
art.scale.set(scale);
const drawing = shapeDrawing(shape, this.state.project);
if (drawing) paintDrawing(row.art, drawing, this.paints);
}
override layout(): void {
this.redraw();
if (drawing) paintDrawing(art, drawing, this.paints);
return clip;
}
}
-36
View File
@@ -1,36 +0,0 @@
/**
* How the shape list of #129 (stage 4) maps between what is on screen and what the model holds.
* The list shows the drawing order upside down – the shape drawn last is the topmost row, as in
* every layer list – so every index has two readings, and dropping a dragged row has a third: the
* place it lands in is counted with the row still in the list, while the model takes it out first.
*
* Kept apart from the widget so the arithmetic can be measured without a canvas.
*/
/** The ids as the list shows them, top first. */
export function shapeRowOrder<T>(ids: readonly T[]): T[] {
return [...ids].reverse();
}
/** The gap a row dropped at `y` falls into, 0…count – the boundary above row 0 being 0. */
export function shapeDropGap(count: number, y: number, rowHeight: number): number {
if (rowHeight <= 0) return 0;
return Math.max(0, Math.min(count, Math.round(y / rowHeight)));
}
/**
* The move a drop makes, in model indices, or null when the row would land where it already is.
* `fromRow` and `gap` are in display positions; a gap below the dragged row counts that row itself,
* which the model does not once it has taken it out.
*/
export function shapeDropMove(
count: number,
fromRow: number,
gap: number,
): { from: number; to: number } | null {
if (fromRow < 0 || fromRow >= count) return null;
const toRow = gap > fromRow ? gap - 1 : gap;
if (toRow === fromRow || toRow < 0 || toRow >= count) return null;
const last = count - 1;
return { from: last - fromRow, to: last - toRow };
}