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
@@ -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