Scene hierarchy: parentId with local positions, tree panel, reparenting and Parent node

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 15:04:56 +02:00
1 parent 551a50ffc9
commit 26d5edc496
22 files changed
+729 -166

No files matched your search

+69 -2
View File
@@ -14,9 +14,13 @@ export interface ListItem {
swatch?: number;
}
/** Pixels the pointer must travel before a press turns into a drag. */
const DRAG_THRESHOLD = 4;
/**
* 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.
* With `onDrop` set, rows can be dragged onto other rows (or onto the empty area below).
*/
export class ListView extends Widget {
private items: ListItem[] = [];
@@ -25,23 +29,37 @@ export class ListView extends Widget {
onSelect?: (id: string | null) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
rowHeight: number = Theme.rowHeight;
emptyText = 'Nothing here yet';
private readonly emptyLabel = new Text({ text: '', style: textStyle('muted') });
private readonly dropMarker = new Graphics();
private drag: { id: string; startX: number; startY: number; active: boolean; targetId: string | null } | null = null;
constructor() {
super();
this.focusable = true;
this.addChild(this.emptyLabel);
this.dropMarker.eventMode = 'none';
this.addChild(this.emptyLabel, this.dropMarker);
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.target === this) this.select(null);
});
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onDragMove(e));
const finish = () => this.onDragEnd();
this.on('pointerup', finish);
this.on('pointerupoutside', finish);
}
get selected(): string | null {
return this.selectedId;
}
/** Id of the row currently hovered by a drag, for tests and styling. */
get dropTarget(): string | null {
return this.drag?.active ? this.drag.targetId : null;
}
setItems(items: ListItem[]): void {
this.items = items;
for (const row of this.rows) row.destroy({ children: true });
@@ -54,12 +72,14 @@ export class ListView extends Widget {
} else {
this.select(item.id);
if (e.detail === 2) this.onActivate?.(item.id);
else if (this.onDrop) this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null };
}
});
row.position.set(0, index * this.rowHeight);
this.addChild(row);
return row;
});
this.addChild(this.dropMarker); // keep the marker above the rows
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
this.layout();
this.redraw();
@@ -103,6 +123,47 @@ export class ListView extends Widget {
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));
this.drawDropMarker();
}
// --- drag & drop -------------------------------------------------------------------------
private onDragMove(e: FederatedPointerEvent): void {
const drag = this.drag;
if (!drag) return;
if (!drag.active) {
if (Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return;
drag.active = true;
this.cursor = 'grabbing';
}
const local = this.toLocal(e.global);
const index = Math.floor(local.y / this.rowHeight);
const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined;
drag.targetId = target && target.id !== drag.id ? target.id : null;
this.invalidate();
}
private onDragEnd(): void {
const drag = this.drag;
this.drag = null;
this.cursor = 'default';
if (!drag?.active) return;
this.invalidate();
this.onDrop?.(drag.id, drag.targetId);
}
/** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */
private drawDropMarker(): void {
this.dropMarker.clear();
const drag = this.drag;
if (!drag?.active) return;
const index = this.items.findIndex((i) => i.id === drag.targetId);
if (index >= 0) {
this.dropMarker.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2).stroke({ width: 2, color: Theme.colors.accent });
} else {
const y = Math.min(this.h, this.items.length * this.rowHeight) - 1;
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
}
}
}
@@ -132,10 +193,16 @@ class Row extends Widget {
override redraw(): void {
const c = Theme.colors;
const indent = Theme.padding + (this.item.depth ?? 0) * 14;
const depth = this.item.depth ?? 0;
const indent = Theme.padding + depth * 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);
// Tree guide: a short "└" mark in front of nested rows.
if (depth > 0) {
const gx = indent - 8;
this.bg.moveTo(gx, 0).lineTo(gx, this.h / 2).lineTo(indent - 2, this.h / 2).stroke({ width: 1, color: c.border });
}
let x = indent;
if (this.item.swatch !== undefined) {
this.bg.roundRect(x, (this.h - 12) / 2, 12, 12, 2).fill(this.item.swatch);