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:
1 parent
8adef83f23
commit
5b9b57fd19
14 files changed
+699
-326
No files matched your search
+32
-16
@@ -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),
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,14 @@ export { Slider } from './widgets/Slider';
|
||||
export { Splitter } from './widgets/Splitter';
|
||||
export { ScrollContainer } from './widgets/ScrollContainer';
|
||||
export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
|
||||
export {
|
||||
SORTABLE_ROW_HEIGHT,
|
||||
SortableList,
|
||||
type SortableItem,
|
||||
type SortableListOptions,
|
||||
type ThumbClip,
|
||||
} from './widgets/SortableList';
|
||||
export { dropGap, dropMove, rowOrder } from './sortable';
|
||||
export { Popup, type PopupOptions } from './widgets/Popup';
|
||||
export {
|
||||
currentMenu,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* 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.
|
||||
* How a sortable list maps between what is on screen and what its owner holds. A layer list shows the
|
||||
* drawing order upside down – the item drawn last is the topmost row – 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 ids as the list shows them, top first. `reversed` puts the model's last item on top. */
|
||||
export function rowOrder<T>(ids: readonly T[], reversed: boolean): T[] {
|
||||
return reversed ? [...ids].reverse() : [...ids];
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
export function dropGap(count: number, y: number, rowHeight: number): number {
|
||||
if (rowHeight <= 0) return 0;
|
||||
return Math.max(0, Math.min(count, Math.round(y / rowHeight)));
|
||||
}
|
||||
@@ -23,14 +23,16 @@ export function shapeDropGap(count: number, y: number, rowHeight: number): numbe
|
||||
* `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(
|
||||
export function dropMove(
|
||||
count: number,
|
||||
fromRow: number,
|
||||
gap: number,
|
||||
reversed: boolean,
|
||||
): { 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;
|
||||
if (!reversed) return { from: fromRow, to: toRow };
|
||||
const last = count - 1;
|
||||
return { from: last - fromRow, to: last - toRow };
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* A list whose rows are dragged into another order – the layer list of every editor that has one:
|
||||
* the shapes of vector art, the layers and frames of pixel art, the layers of a tilemap. Each row may
|
||||
* carry a small picture the owner paints and an eye that hides the item without removing it.
|
||||
*
|
||||
* The list never changes the order itself. A drop reports the move in the owner's indices, the owner
|
||||
* runs its command, and the next `setItems` shows the result.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { dropGap, dropMove, rowOrder } from '../sortable';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
|
||||
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||
export const SORTABLE_ROW_HEIGHT = 30;
|
||||
const THUMB = 22;
|
||||
const PAD = 4;
|
||||
/** Width at the right end of a row that belongs to the eye. */
|
||||
const EYE_SLOT = 26;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
export interface SortableItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Small text at the right end, before the eye. */
|
||||
hint?: string;
|
||||
/** Drawn faded, with a closed eye. */
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
/** Part of the picture square that is shown, in its own coordinates. */
|
||||
export interface ThumbClip {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface SortableListOptions {
|
||||
/** The model's last item is the topmost row, as in every layer list: it is drawn last. */
|
||||
reversed?: boolean;
|
||||
/**
|
||||
* Paints the item's picture into `art`, whose origin is the top-left corner of a `size` square. It
|
||||
* may move and scale `art`; what it paints is cut to the square, or to the box it returns.
|
||||
*/
|
||||
thumb?: (id: string, art: Graphics, size: number) => ThumbClip | void;
|
||||
/** A row pressed. `toggle` is true with Ctrl or Shift held, where `multiSelect` is on. */
|
||||
onPick: (id: string, toggle: boolean) => void;
|
||||
/** A row dropped somewhere else, in the owner's indices. */
|
||||
onMove: (from: number, to: number) => void;
|
||||
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
|
||||
onToggleHidden?: (id: string) => void;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
box: Container;
|
||||
art: Graphics;
|
||||
clip: Graphics;
|
||||
label: Text;
|
||||
hint: Text;
|
||||
eye: Graphics;
|
||||
}
|
||||
|
||||
interface RowDrag {
|
||||
/** Index of the pressed row in display order. */
|
||||
from: number;
|
||||
/** The pressed row is to be picked once the pointer is up. */
|
||||
pick: boolean;
|
||||
startY: number;
|
||||
y: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class SortableList extends Widget {
|
||||
private readonly background = new Graphics();
|
||||
private readonly marker = new Graphics();
|
||||
private readonly rows: Row[] = [];
|
||||
/** Display order, top first. */
|
||||
private shown: SortableItem[] = [];
|
||||
private selected: readonly string[] = [];
|
||||
private hoveredRow = -1;
|
||||
private drag: RowDrag | null = null;
|
||||
|
||||
constructor(private readonly options: SortableListOptions) {
|
||||
super();
|
||||
this.cursor = Theme.hoverCursor;
|
||||
this.addChild(this.background);
|
||||
this.marker.eventMode = 'none';
|
||||
this.addChild(this.marker);
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
/** The items in the owner's order, and which of them are marked. */
|
||||
setItems(items: readonly SortableItem[], selectedIds: readonly string[]): void {
|
||||
const countChanged = items.length !== this.shown.length;
|
||||
this.shown = rowOrder(items, this.options.reversed ?? false);
|
||||
this.selected = selectedIds;
|
||||
while (this.rows.length < this.shown.length) this.rows.push(this.buildRow());
|
||||
while (this.rows.length > this.shown.length) this.rows.pop()!.box.destroy({ children: true });
|
||||
if (countChanged) this.drag = null;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: this.minWidth, h: Math.max(SORTABLE_ROW_HEIGHT, this.shown.length * SORTABLE_ROW_HEIGHT) };
|
||||
}
|
||||
|
||||
/** The ids as the list shows them, top first – for the check scripts. */
|
||||
get shownIds(): string[] {
|
||||
return this.shown.map((item) => item.id);
|
||||
}
|
||||
|
||||
/** Where an item's row sits on screen (test hook); null when the list does not show it. */
|
||||
rowGlobal(id: string): { x: number; y: number; w: number; h: number } | null {
|
||||
const index = this.shown.findIndex((item) => item.id === id);
|
||||
if (index < 0) return null;
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x, y: origin.y + index * SORTABLE_ROW_HEIGHT, w: this.w, h: SORTABLE_ROW_HEIGHT };
|
||||
}
|
||||
|
||||
/** The centre of an item's picture (test hook); null without pictures or without that row. */
|
||||
thumbGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row || !this.options.thumb) return null;
|
||||
return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) };
|
||||
}
|
||||
|
||||
/** The centre of an item's eye (test hook); null without eyes or without that row. */
|
||||
eyeGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row || !this.options.onToggleHidden) return null;
|
||||
return { x: row.x + row.w - EYE_SLOT / 2, y: row.y + row.h / 2 };
|
||||
}
|
||||
|
||||
private buildRow(): Row {
|
||||
const box = new Container();
|
||||
const art = new Graphics();
|
||||
const clip = new Graphics();
|
||||
const label = new Text({ text: '', style: textStyle('body') });
|
||||
const hint = new Text({ text: '', style: textStyle('small') });
|
||||
const eye = new Graphics();
|
||||
box.eventMode = 'none';
|
||||
box.addChild(art, clip, label, hint, eye);
|
||||
art.mask = clip;
|
||||
this.addChildAt(box, this.getChildIndex(this.marker));
|
||||
return { box, art, clip, label, hint, eye };
|
||||
}
|
||||
|
||||
private rowAt(y: number): number {
|
||||
const index = Math.floor(y / SORTABLE_ROW_HEIGHT);
|
||||
return index >= 0 && index < this.shown.length ? index : -1;
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const index = this.rowAt(local.y);
|
||||
if (index < 0) return;
|
||||
e.stopPropagation();
|
||||
const id = this.shown[index]!.id;
|
||||
if (this.options.onToggleHidden && local.x >= this.w - EYE_SLOT) {
|
||||
this.options.onToggleHidden(id);
|
||||
return;
|
||||
}
|
||||
if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) {
|
||||
this.options.onPick(id, true);
|
||||
return;
|
||||
}
|
||||
// Picked on release: a pick rebuilds what the list stands in, which would end the drag.
|
||||
const pick = this.selected.length !== 1 || this.selected[0] !== id;
|
||||
this.drag = { from: index, pick, startY: local.y, y: local.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();
|
||||
const id = this.shown[drag.from]?.id;
|
||||
if (drag.active) {
|
||||
const gap = dropGap(this.shown.length, drag.y, SORTABLE_ROW_HEIGHT);
|
||||
const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false);
|
||||
if (move) this.options.onMove(move.from, move.to);
|
||||
}
|
||||
if (drag.pick && id) this.options.onPick(id, false);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.background.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.control);
|
||||
this.marker.clear();
|
||||
const eyes = Boolean(this.options.onToggleHidden);
|
||||
const thumbs = this.options.thumb;
|
||||
const textX = thumbs ? PAD * 2 + THUMB : PAD * 2;
|
||||
for (const [index, item] of this.shown.entries()) {
|
||||
const row = this.rows[index]!;
|
||||
const y = index * SORTABLE_ROW_HEIGHT;
|
||||
row.box.position.set(0, y);
|
||||
const selected = this.selected.includes(item.id);
|
||||
if (selected || index === this.hoveredRow)
|
||||
this.background
|
||||
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
||||
|
||||
const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2;
|
||||
row.art.clear();
|
||||
row.art.position.set(PAD, thumbY);
|
||||
row.art.scale.set(1);
|
||||
row.clip.clear();
|
||||
if (thumbs) {
|
||||
const clip = thumbs(item.id, row.art, THUMB) ?? { x: 0, y: 0, w: THUMB, h: THUMB };
|
||||
row.clip.rect(PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff);
|
||||
}
|
||||
row.art.alpha = item.hidden ? 0.35 : 1;
|
||||
|
||||
const eyeW = eyes ? EYE_SLOT : 0;
|
||||
row.hint.text = item.hint ?? '';
|
||||
const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0;
|
||||
row.hint.position.set(
|
||||
Math.round(this.w - eyeW - PAD - row.hint.width),
|
||||
(SORTABLE_ROW_HEIGHT - row.hint.height) / 2,
|
||||
);
|
||||
row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted');
|
||||
row.label.text = truncate(
|
||||
item.label,
|
||||
row.label.style,
|
||||
Math.max(0, this.w - textX - hintW - eyeW - PAD),
|
||||
);
|
||||
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
||||
|
||||
row.eye.clear();
|
||||
if (eyes) drawEye(row.eye, this.w - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden);
|
||||
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
|
||||
}
|
||||
if (this.drag?.active) {
|
||||
const y = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
}
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
|
||||
/** An open eye, or a closed one struck through – drawn rather than a glyph, so it stays crisp. */
|
||||
function drawEye(g: Graphics, x: number, y: number, open: boolean): void {
|
||||
const color = open ? Theme.colors.text : Theme.colors.textMuted;
|
||||
g.ellipse(x, y, 6, 3.5).stroke({ width: 1.2, color });
|
||||
if (open) g.circle(x, y, 1.8).fill(color);
|
||||
else
|
||||
g.moveTo(x - 6, y + 5)
|
||||
.lineTo(x + 6, y - 5)
|
||||
.stroke({ width: 1.2, color });
|
||||
}
|
||||
Reference in new issue
Block a user