Multiselection and container assets: create container from selection, template instances

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 17:40:51 +02:00
1 parent 91ee2feadd
commit 0b78382bcc
26 files changed
+897 -114

No files matched your search

+6 -4
View File
@@ -5,9 +5,9 @@ import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -77,7 +77,7 @@ export class AssetPanel extends Panel {
return;
}
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): void {
@@ -100,7 +100,9 @@ export class AssetPanel extends Panel {
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: usages > 0
: asset.type === 'container' && usages > 0
? `${usages} placed container(s) are removed from scenes together with their content.`
: usages > 0
? `${usages} placed instance(s) are removed from scenes as well.`
: 'This cannot be undone with Ctrl+Z after reload.';
new Dialog(`Delete "${asset.name}"?`, detail, [
+19 -10
View File
@@ -1,15 +1,17 @@
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { treeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* Drag a row onto another to parent it there; drop it on the empty space below the rows to
* move it to the top level. The object keeps its world position either way.
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
@@ -19,9 +21,11 @@ export class HierarchyPanel extends Panel {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id) => {
this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene;
state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null);
if (!scene) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
@@ -35,18 +39,22 @@ export class HierarchyPanel extends Panel {
if (!scene) return;
const object = scene.objects.find((o) => o.id === id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{
label: 'Add collider',
disabled: !type || type === 'collider',
disabled: !type || type === 'collider' || many,
onSelect: () => {
const command = new AddColliderCommand(scene.id, id);
if (state.execute(command)) state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (state.execute(command)) state.select(objectSelection(scene.id, [command.objectId]));
},
},
{ label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
]).open(x, y);
};
this.scroll.setContent(this.list);
@@ -87,5 +95,6 @@ export class HierarchyPanel extends Panel {
private syncSelection(): void {
const s = this.state.selection;
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
}
}
+35 -4
View File
@@ -20,8 +20,9 @@ import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
import { type EditorState, objectSelection, type Selection } from '../state';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
@@ -80,7 +81,8 @@ export class Inspector extends Panel {
} else if (s.kind === 'asset') {
this.buildAsset(s.assetId);
} else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
if (s.objectIds.length > 1) this.buildObjects(s.sceneId, s.objectIds);
else this.buildObject(s.sceneId, s.objectId);
} else {
this.buildNode(s.assetId, s.nodeId);
}
@@ -96,6 +98,7 @@ export class Inspector extends Panel {
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
if (s.kind === 'object') {
if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`;
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
@@ -204,6 +207,27 @@ export class Inspector extends Panel {
// --- scene objects -------------------------------------------------------------------
/** Several objects: what they are, and the actions that apply to all of them at once. */
private buildObjects(sceneId: string, objectIds: string[]): void {
const scene = findScene(this.state.project, sceneId);
if (!scene) return;
this.body.add(new Label(`${objectIds.length} OBJECTS SELECTED`, { style: 'title' }));
for (const id of objectIds) {
const object = findSceneObject(scene, id);
const asset = object && findAsset(this.state.project, object.assetId);
if (object && asset) this.body.add(new Label(`${object.name || asset.name} · ${asset.type}`, { style: 'muted' }));
}
this.body.add(new Label(''));
this.body.add(new Label('Ctrl-click adds or removes objects.', { style: 'small' }));
this.body.add(new Label('Dragging one in the scene moves all of them.', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Create container', { onClick: () => createContainer(this.state, sceneId, objectIds) }));
this.body.add(new Label('Groups the selection into a reusable container', { style: 'small' }));
this.body.add(new Label('asset placed at its centre (Ctrl+G).', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(this.state, sceneId, objectIds) }));
}
private buildObject(sceneId: string, objectId: string): void {
const scene = findScene(this.state.project, sceneId);
const object = scene && findSceneObject(scene, objectId);
@@ -293,6 +317,13 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
if (asset.type === 'container') {
const count = asset.objects.length;
this.body.add(new Label('CONTENT', { style: 'title' }));
this.body.add(new Label(count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`, { style: 'muted' }));
if (count > 0) this.body.add(new Label('Its content is listed below it in the hierarchy.', { style: 'small' }));
}
// Sprites (and other bodies) get their collider with one click: a child sized like the object.
if (asset.type !== 'collider') {
this.body.add(new Label(''));
@@ -301,7 +332,7 @@ export class Inspector extends Panel {
variant: 'ghost',
onClick: () => {
const command = new AddColliderCommand(sceneId, objectId);
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(sceneId, [command.objectId]));
},
}),
);
@@ -483,7 +514,7 @@ function sameTarget(a: Selection, b: Selection): boolean {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(',');
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}