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:
1 parent
551a50ffc9
commit
26d5edc496
22 files changed
+729
-166
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user