Variables of a behavior, the fields of a struct, the ports of a start or end node and the items of a list could only be added and removed. Changing their order meant removing and adding them again. Now each of them is dragged by the mark in front of its row. - sortBlocks (ui/blockDrag.ts) makes the rows of one column sortable. A block is every row it covers, so a struct moves with its fields and a list item with its nested list. The block is dimmed while it is carried, an accent line shows where it lands, and a scrolled panel follows the pointer at its edge. Less than 4 px stays a click. - The arithmetic is pure and lives in ui/sortable.ts: blockGap, gapLineY, dropSlot and edgeScroll. dropIndex of the tile strip moved there too, so the reorder maths sits in one file. - MoveVariableCommand, MoveStructFieldCommand and MovePortCommand build on the existing MoveCommand. Undo moves back. - Lists need no command. InlineList replaces the list as a whole, as it does for every other edit of its items. - The grip is the kind mark, for a list item its number. Moving only happens among siblings: a field stays in its struct. - On a Struct asset the move runs through WithStructTypes. Typed variables, Pack/Expand ports and split outputs follow the new order. - Values and edges hang on names, so only the order changes. Nodes running a behavior read their ports from the start node, the new order reaches them without a copy. - Not sortable by design: the variables of a placed object, the fields of a typed struct (moved on the Struct asset) and the picked ports of Variables/Properties nodes. - Test hook __gm.inspectorMark(label) finds the mark of a row. Tests: - Gate on melektaus-atx: 235 files, 3572 tests, CHECK EXIT 0 (+9). - tests/core/reorder.test.ts: a variable, a field two levels deep, a Struct asset reaching a typed variable and Pack, and start ports reaching the calling node with edges and result kept. - e2e 22: ALL PASSED, 39 checks (+4, sum 1739). 22f-iii drags a variable past a struct of several rows, then a field inside it. - Counter-probes: dropSlot by top edge instead of middle and a field move that ignores its path fail 3 unit tests. With sortBlocks switched off, the three order checks of 22f-iii fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
import { Graphics } from 'pixi.js';
|
||
import {
|
||
AddTilePictureCommand,
|
||
MoveTilePictureCommand,
|
||
RemoveTilePictureCommand,
|
||
} from '../../core/commands/tileCommands';
|
||
import type { TileAsset } from '../../core/model/Asset';
|
||
import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||
import {
|
||
Button,
|
||
dropIndex,
|
||
type DropSpot,
|
||
HStack,
|
||
Label,
|
||
ListView,
|
||
Menu,
|
||
type MenuItem,
|
||
ScrollContainer,
|
||
Theme,
|
||
VStack,
|
||
} from '../../ui';
|
||
import type { EditorState } from '../state';
|
||
|
||
const WIDTH = 210;
|
||
|
||
/**
|
||
* The picture list beside the board (#118): one row per animation step, in the order they run.
|
||
* Rows are addressed by their place rather than by the picture they name – the same block may be
|
||
* used twice in one animation, and then two rows carry the same id.
|
||
*/
|
||
export class PictureStrip extends VStack {
|
||
/** Public for the check scripts. */
|
||
readonly addButton: Button;
|
||
readonly removeButton: Button;
|
||
readonly list = new ListView();
|
||
private readonly title = new Label('Pictures', { style: 'muted' });
|
||
private readonly scroll = new ScrollContainer();
|
||
private readonly border = new Graphics();
|
||
private assetId = '';
|
||
private selected = -1;
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super({ gap: Theme.gap, padding: Theme.padding });
|
||
this.minWidth = WIDTH;
|
||
this.addButton = new Button('+ Picture', { onClick: () => this.openPicturePicker() });
|
||
this.removeButton = new Button('Remove', { onClick: () => this.removeSelected() });
|
||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||
header.add(this.title);
|
||
this.add(header);
|
||
this.list.emptyText = 'No pictures yet';
|
||
this.list.onSelect = (id) => this.select(id === null ? -1 : Number(id));
|
||
this.list.onDrop = (draggedId, spot) => this.drop(Number(draggedId), spot);
|
||
this.list.onContextMenu = (id, x, y) => this.openRowMenu(Number(id), x, y);
|
||
this.scroll.setContent(this.list);
|
||
this.add(this.scroll, 1);
|
||
const buttons = new HStack({ gap: Theme.gap });
|
||
buttons.add(this.addButton, 1);
|
||
buttons.add(this.removeButton, 1);
|
||
this.add(buttons);
|
||
this.addChildAt(this.border, 0);
|
||
|
||
state.on('project', () => this.refresh());
|
||
state.on('load', () => this.show(''));
|
||
}
|
||
|
||
show(assetId: string): void {
|
||
if (this.assetId === assetId) {
|
||
this.refresh();
|
||
return;
|
||
}
|
||
this.assetId = assetId;
|
||
this.selected = -1;
|
||
this.refresh();
|
||
}
|
||
|
||
/** The rows as shown and which one is picked – the check scripts read this. */
|
||
debugInfo(): { pictures: string[]; selected: number } {
|
||
return { pictures: this.asset()?.pictures.map((id) => this.labelOf(id)) ?? [], selected: this.selected };
|
||
}
|
||
|
||
private asset(): TileAsset | undefined {
|
||
const asset = this.state.project.assets.find((a) => a.id === this.assetId);
|
||
return asset?.type === 'tile' ? asset : undefined;
|
||
}
|
||
|
||
private labelOf(id: string): string {
|
||
return textureRefLabel(this.state.project, id);
|
||
}
|
||
|
||
private refresh(): void {
|
||
const asset = this.asset();
|
||
const pictures = asset?.pictures ?? [];
|
||
if (this.selected >= pictures.length) this.selected = pictures.length - 1;
|
||
this.list.setItems(
|
||
pictures.map((id, index) => ({
|
||
id: String(index),
|
||
label: this.labelOf(id),
|
||
hint: String(index + 1),
|
||
})),
|
||
);
|
||
this.list.select(this.selected >= 0 ? String(this.selected) : null, true);
|
||
this.scroll.relayoutContent();
|
||
this.addButton.setEnabled(asset !== undefined);
|
||
this.removeButton.setEnabled(this.selected >= 0);
|
||
this.title.setText(pictures.length === 1 ? '1 picture' : `${pictures.length} pictures`);
|
||
}
|
||
|
||
private select(index: number): void {
|
||
this.selected = index;
|
||
this.removeButton.setEnabled(index >= 0);
|
||
}
|
||
|
||
private removeSelected(): void {
|
||
const asset = this.asset();
|
||
if (!asset || this.selected < 0) return;
|
||
this.state.execute(new RemoveTilePictureCommand(asset.id, this.selected));
|
||
}
|
||
|
||
/** Every texture and every frame; a tile's block is one of them, whole or cut out. */
|
||
private openPicturePicker(): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const items: MenuItem[] = pictureChoices(this.state.project).map((choice) => ({
|
||
label: choice.label,
|
||
onSelect: () => this.state.execute(new AddTilePictureCommand(asset.id, choice.id)),
|
||
}));
|
||
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
|
||
new Menu(items, { searchable: true }).openBelow(this.addButton);
|
||
}
|
||
|
||
private openRowMenu(index: number, x: number, y: number): Menu | undefined {
|
||
const asset = this.asset();
|
||
if (!asset) return undefined;
|
||
this.select(index);
|
||
this.list.select(String(index), true);
|
||
const menu = new Menu([
|
||
{
|
||
label: 'Remove',
|
||
danger: true,
|
||
onSelect: () => this.state.execute(new RemoveTilePictureCommand(asset.id, index)),
|
||
},
|
||
]);
|
||
menu.open(x, y);
|
||
return menu;
|
||
}
|
||
|
||
/** Dragging a row to another place is what reorders the animation. */
|
||
private drop(from: number, spot: DropSpot): void {
|
||
const asset = this.asset();
|
||
if (!asset) return;
|
||
const to = dropIndex(from, spot, asset.pictures.length);
|
||
if (to === from || to < 0) return;
|
||
if (this.state.execute(new MoveTilePictureCommand(asset.id, from, to))) this.select(to);
|
||
}
|
||
|
||
override redraw(): void {
|
||
super.redraw();
|
||
this.border.clear().rect(0, 0, 1, this.h).fill(Theme.colors.border);
|
||
}
|
||
}
|