Canvas-only 2D game editor/runtime built on PixiJS v8 + Vite + TypeScript. - core: asset model (Scene/Camera/Sprite), scene objects with sparse overrides, property schema + resolve, command stack (undo/redo), serialization and project store interface (16 unit tests) - ui: custom in-canvas widget toolkit (panels, fields, color picker, list/scroll, popup) with a hidden <input> keyboard bridge - editor: asset/hierarchy panels, schema-driven inspector, toolbar - viewport: box rendering, camera frame, selection gizmo, drag/zoom/pan - runtime: world/entity instantiation for play mode, input, camera follow - api: PHP JSON project store with revision counter (409 on conflict) - tools: CDP-based e2e smoke test Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
152 lines
5.3 KiB
TypeScript
152 lines
5.3 KiB
TypeScript
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
|
import { Theme, textStyle } from '../Theme';
|
|
import { type Size, Widget } from '../Widget';
|
|
import { truncate } from './Label';
|
|
|
|
export interface ListItem {
|
|
id: string;
|
|
label: string;
|
|
/** Small secondary text on the right (e.g. the asset type). */
|
|
hint?: string;
|
|
/** Left indent in levels (hierarchies). */
|
|
depth?: number;
|
|
/** Optional marker color drawn as a small square before the label. */
|
|
swatch?: number;
|
|
}
|
|
|
|
/**
|
|
* Single-select list of rows. Rows are rebuilt from `setItems()`; selection is by id so
|
|
* it survives rebuilds. Emits select, activate (double-click / Enter) and context events.
|
|
*/
|
|
export class ListView extends Widget {
|
|
private items: ListItem[] = [];
|
|
private rows: Row[] = [];
|
|
private selectedId: string | null = null;
|
|
onSelect?: (id: string | null) => void;
|
|
onActivate?: (id: string) => void;
|
|
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
|
|
rowHeight = Theme.rowHeight;
|
|
emptyText = 'Nothing here yet';
|
|
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
|
|
|
|
constructor() {
|
|
super();
|
|
this.focusable = true;
|
|
this.addChild(this.emptyLabel);
|
|
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
|
if (e.target === this) this.select(null);
|
|
});
|
|
}
|
|
|
|
get selected(): string | null {
|
|
return this.selectedId;
|
|
}
|
|
|
|
setItems(items: ListItem[]): void {
|
|
this.items = items;
|
|
for (const row of this.rows) row.destroy({ children: true });
|
|
this.rows = items.map((item, index) => {
|
|
const row = new Row(item, this.rowHeight);
|
|
row.on('pointerdown', (e: FederatedPointerEvent) => {
|
|
if (e.button === 2) {
|
|
this.select(item.id);
|
|
this.onContextMenu?.(item.id, e.global.x, e.global.y);
|
|
} else {
|
|
this.select(item.id);
|
|
if (e.detail === 2) this.onActivate?.(item.id);
|
|
}
|
|
});
|
|
row.position.set(0, index * this.rowHeight);
|
|
this.addChild(row);
|
|
return row;
|
|
});
|
|
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
|
|
this.layout();
|
|
this.redraw();
|
|
}
|
|
|
|
select(id: string | null, silent = false): void {
|
|
if (id === this.selectedId) return;
|
|
this.selectedId = id;
|
|
this.redraw();
|
|
if (!silent) this.onSelect?.(id);
|
|
}
|
|
|
|
override measure(): Size {
|
|
return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) };
|
|
}
|
|
|
|
override onKeyDown(event: KeyboardEvent): boolean {
|
|
const index = this.items.findIndex((i) => i.id === this.selectedId);
|
|
if (event.key === 'ArrowDown') {
|
|
const next = this.items[Math.min(this.items.length - 1, index + 1)];
|
|
if (next) this.select(next.id);
|
|
return true;
|
|
}
|
|
if (event.key === 'ArrowUp') {
|
|
const prev = this.items[Math.max(0, index - 1)];
|
|
if (prev) this.select(prev.id);
|
|
return true;
|
|
}
|
|
if (event.key === 'Enter' && this.selectedId) {
|
|
this.onActivate?.(this.selectedId);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
override layout(): void {
|
|
for (const row of this.rows) row.resize(this.w, this.rowHeight);
|
|
}
|
|
|
|
override redraw(): void {
|
|
for (const row of this.rows) row.setSelected(row.item.id === this.selectedId, this.focused);
|
|
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
|
|
this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2));
|
|
}
|
|
}
|
|
|
|
class Row extends Widget {
|
|
private readonly bg = new Graphics();
|
|
private readonly labelText = new Text({ text: '', style: textStyle('body') });
|
|
private readonly hint = new Text({ text: '', style: textStyle('small') });
|
|
private hovered = false;
|
|
private selected = false;
|
|
private listFocused = false;
|
|
|
|
constructor(readonly item: ListItem, height: number) {
|
|
super();
|
|
this.cursor = 'pointer';
|
|
this.minHeight = height;
|
|
this.addChild(this.bg, this.labelText, this.hint);
|
|
this.on('pointerover', () => { this.hovered = true; this.invalidate(); });
|
|
this.on('pointerout', () => { this.hovered = false; this.invalidate(); });
|
|
}
|
|
|
|
setSelected(selected: boolean, listFocused: boolean): void {
|
|
if (selected === this.selected && listFocused === this.listFocused) return;
|
|
this.selected = selected;
|
|
this.listFocused = listFocused;
|
|
this.invalidate();
|
|
}
|
|
|
|
override redraw(): void {
|
|
const c = Theme.colors;
|
|
const indent = Theme.padding + (this.item.depth ?? 0) * 14;
|
|
this.bg.clear();
|
|
if (this.selected) this.bg.rect(0, 0, this.w, this.h).fill(this.listFocused ? c.selection : c.controlActive);
|
|
else if (this.hovered) this.bg.rect(0, 0, this.w, this.h).fill(c.controlHover);
|
|
let x = indent;
|
|
if (this.item.swatch !== undefined) {
|
|
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);
|
|
this.bg.roundRect(x + 0.5, (this.h - 12) / 2 + 0.5, 11, 11, 2).stroke({ width: 1, color: c.border });
|
|
x += 18;
|
|
}
|
|
this.hint.text = this.item.hint ?? '';
|
|
const hintW = this.hint.text ? this.hint.width + Theme.padding : 0;
|
|
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
|
|
this.labelText.position.set(x, Math.round((this.h - this.labelText.height) / 2));
|
|
this.hint.position.set(Math.round(this.w - Theme.padding - this.hint.width), Math.round((this.h - this.hint.height) / 2));
|
|
}
|
|
}
|