Add a Container tab and propagate template edits

The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.

Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 20:19:54 +02:00
1 parent 6bedc948be
commit 9d93020bd8
35 files changed
+1452 -363

No files matched your search

+22 -5
View File
@@ -3,6 +3,7 @@ import { centreY } from '../math';
import { Theme, textStyle } from '../Theme';
import { type Size, Widget } from '../Widget';
import { ListRow } from './ListRow';
import type { Popup } from './Popup';
export interface ListItem {
id: string;
@@ -46,9 +47,12 @@ export class ListView extends Widget {
private rows: ListRow[] = [];
private selectedId: string | null = null;
private highlighted = new Set<string>();
/** Row an open context menu acts on – drawn like a selected row, but only until it closes. */
private contextId: string | null = null;
onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Returning the opened menu keeps its row marked for as long as it is up. */
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
onDrop?: (draggedId: string, targetId: string | null) => void;
/**
@@ -94,6 +98,7 @@ export class ListView extends Widget {
});
this.addChild(this.dropMarker); // keep the marker above the rows
if (this.selectedId && !items.some((i) => i.id === this.selectedId)) this.selectedId = null;
if (this.contextId && !items.some((i) => i.id === this.contextId)) this.contextId = null;
this.layout();
this.redraw();
}
@@ -106,6 +111,12 @@ export class ListView extends Widget {
if (!silent) this.onSelect?.(id);
}
private setContext(id: string | null): void {
if (id === this.contextId) return;
this.contextId = id;
this.redraw();
}
/** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
highlight(ids: Iterable<string>): void {
this.highlighted = new Set(ids);
@@ -153,7 +164,11 @@ export class ListView extends Widget {
override redraw(): void {
for (const row of this.rows) {
row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused);
const id = row.item.id;
row.setSelected(
id === this.selectedId || id === this.contextId || this.highlighted.has(id),
this.focused,
);
}
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, centreY(this.rowHeight, this.emptyLabel.height));
@@ -169,9 +184,11 @@ export class ListView extends Widget {
if (e.button === 0 && row.hitsChevron(row.toLocal(e.global).x)) {
this.onToggle?.(item.id, !item.expanded);
} else if (e.button === 2) {
// A right-click inside a multi-selection keeps it (the menu acts on all of it).
if (!this.highlighted.has(item.id)) this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
// Marked, not selected: selecting also loads the row into the editor, which would swap the
// very tab out from under a menu opened to place something into it.
const menu = this.onContextMenu?.(item.id, e.global.x, e.global.y);
this.setContext(menu ? item.id : null);
if (menu) menu.onClose = () => this.setContext(null);
} else if (modifiers.ctrl || modifiers.shift) {
this.onSelect?.(item.id, modifiers);
} else {