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:
1 parent
6bedc948be
commit
9d93020bd8
35 files changed
+1452
-363
No files matched your search
@@ -0,0 +1,65 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { Theme } from '../../ui';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
import { SceneViewport } from '../../viewport/SceneViewport';
|
||||
import { type View, worldToScreen } from '../../viewport/view';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Arm length of the origin cross, in screen pixels. */
|
||||
const ORIGIN_ARM = 14;
|
||||
|
||||
/**
|
||||
* The editable view of one container's template. Everything about picking, dragging, the marquee
|
||||
* and the labels is the scene view's – the only difference is which asset holds the objects.
|
||||
*/
|
||||
export class ContainerCanvas extends SceneViewport {
|
||||
private assetId = '';
|
||||
private readonly originMark = new Graphics();
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.content.addChild(this.originMark);
|
||||
}
|
||||
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override holder(): ObjectHolder | undefined {
|
||||
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
|
||||
return asset?.type === 'container' ? asset : undefined;
|
||||
}
|
||||
|
||||
/** A container has no background of its own – painting one would invent a scene it is not in. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
super.beforeItems();
|
||||
this.originMark.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin, marked because it carries meaning here: it is the point the container is held by
|
||||
* when placed, so where the content sits relative to it is the thing being authored.
|
||||
*/
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
super.afterItems(items, view);
|
||||
const at = worldToScreen(view, 0, 0);
|
||||
this.originMark
|
||||
.moveTo(at.x - ORIGIN_ARM, at.y)
|
||||
.lineTo(at.x + ORIGIN_ARM, at.y)
|
||||
.moveTo(at.x, at.y - ORIGIN_ARM)
|
||||
.lineTo(at.x, at.y + ORIGIN_ARM)
|
||||
.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
|
||||
this.originMark.circle(at.x, at.y, 3).stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import type { ContainerAsset } from '../../core/model/Asset';
|
||||
import type { Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, listContainers } from '../../core/model/Project';
|
||||
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { ContainerCanvas } from './ContainerCanvas';
|
||||
|
||||
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
|
||||
const INFO_WIDTH = 110;
|
||||
|
||||
/**
|
||||
* The Container tab. It follows the selection, but keeps showing the last container when the
|
||||
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
|
||||
*/
|
||||
export class ContainerEditor extends VStack {
|
||||
private readonly canvas: ContainerCanvas;
|
||||
private readonly picker: Dropdown;
|
||||
private readonly info: Label;
|
||||
private readonly headerBg = new Graphics();
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
|
||||
header.minHeight = Theme.controlHeight + Theme.padding * 2;
|
||||
header.addChildAt(this.headerBg, 0);
|
||||
|
||||
header.add(new Label('Container', { style: 'muted' }));
|
||||
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
|
||||
header.add(this.picker);
|
||||
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
|
||||
header.add(new Spacer(), 1);
|
||||
this.info = new Label('', { style: 'small', align: 'right' });
|
||||
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
|
||||
this.info.minWidth = INFO_WIDTH;
|
||||
header.add(this.info);
|
||||
|
||||
this.canvas = new ContainerCanvas(state);
|
||||
this.add(header);
|
||||
this.add(this.canvas, 1);
|
||||
|
||||
state.on('selection', () => this.followSelection());
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => {
|
||||
this.canvas.show('');
|
||||
this.followSelection();
|
||||
});
|
||||
this.followSelection();
|
||||
}
|
||||
|
||||
/** The container currently shown – the check scripts read this. */
|
||||
get currentAssetId(): string {
|
||||
return this.canvas.currentAssetId;
|
||||
}
|
||||
|
||||
/** For the check scripts: the picker's width must not follow the name it happens to show. */
|
||||
get pickerWidth(): number {
|
||||
return this.picker.w;
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
this.canvas.show(assetId);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Middle of the view – where the Assets panel drops what is placed into this template. */
|
||||
worldCenter(): Vec2 {
|
||||
return this.canvas.worldCenter();
|
||||
}
|
||||
|
||||
/** Where a template object is drawn, in global screen coordinates – for the check scripts. */
|
||||
objectScreen(objectId: string): { x: number; y: number } | null {
|
||||
return this.canvas.objectScreen(objectId);
|
||||
}
|
||||
|
||||
/** The drawing area without the header, so a check can ask whether a point is on screen. */
|
||||
canvasRect(): { x: number; y: number; w: number; h: number } {
|
||||
const p = this.canvas.getGlobalPosition();
|
||||
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
|
||||
}
|
||||
|
||||
private followSelection(): void {
|
||||
const id = this.state.selectedContainerId;
|
||||
// Null merely means "the selection is about something else"; the tab keeps its subject.
|
||||
if (id) this.canvas.show(id);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
private asset(): ContainerAsset | null {
|
||||
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
|
||||
return asset && asset.type === 'container' ? asset : null;
|
||||
}
|
||||
|
||||
private containers(): ContainerAsset[] {
|
||||
return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
private openPicker(): void {
|
||||
const current = this.canvas.currentAssetId;
|
||||
const items: MenuItem[] = this.containers().map((asset) => ({
|
||||
label: asset.id === current ? `● ${asset.name}` : asset.name,
|
||||
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.picker);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
// A deleted container leaves the canvas empty; fall back to whatever container is left.
|
||||
if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
|
||||
const asset = this.asset();
|
||||
this.picker.setText(asset ? asset.name : 'No container');
|
||||
const count = asset?.objects.length ?? 0;
|
||||
this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.headerBg.clear();
|
||||
const h = Theme.controlHeight + Theme.padding * 2;
|
||||
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
|
||||
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user