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:
1 parent
91ee2feadd
commit
0b78382bcc
26 files changed
+897
-114
No files matched your search
@@ -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, [
|
||||
|
||||
@@ -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 : []);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user