289 lines
12 KiB
TypeScript
289 lines
12 KiB
TypeScript
import type { Command } from '../../core/commands/Command';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
|
||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||
import { deviationMarks } from '../../core/properties/deviation';
|
||
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||
import {
|
||
addCollider,
|
||
addRigidbody,
|
||
canAddCollider,
|
||
canAddRigidbody,
|
||
createContainer,
|
||
removeObjects,
|
||
} from '../actions';
|
||
import { hasAssetEditor } from '../assetTabs';
|
||
import { ASSET_ICON_SVG } from './assetIcons';
|
||
import { type EditorState, objectSelection } from '../state';
|
||
|
||
/**
|
||
* Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always
|
||
* keeps world position. Fold state is view state kept here, never in the project.
|
||
*/
|
||
export class HierarchyPanel extends Panel {
|
||
private readonly list = new ListView();
|
||
private readonly scroll = new ScrollContainer();
|
||
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
|
||
private readonly expanded = new Set<string>();
|
||
/**
|
||
* Which objects are listed. A function, not an id, so a container renamed or swapped under the
|
||
* panel needs no notification; the Container tab points this at the template it edits.
|
||
*/
|
||
private source: () => ObjectHolder | undefined = () => this.state.activeScene;
|
||
/** Id of the holder the rows were built from, to notice when the source starts naming another. */
|
||
private shownHolderId = '';
|
||
|
||
constructor(private readonly state: EditorState) {
|
||
super({ title: 'Scene objects', padding: 0, gap: 0 });
|
||
this.minWidth = 160;
|
||
this.list.emptyText = 'Place assets here';
|
||
this.list.onSelect = (id, modifiers) => {
|
||
const holder = this.holder();
|
||
if (!holder) return;
|
||
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
|
||
else state.select(id ? objectSelection(holder.id, [id]) : null);
|
||
};
|
||
this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
|
||
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
|
||
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
|
||
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
|
||
this.scroll.setContent(this.list);
|
||
this.content.add(this.scroll, 1);
|
||
|
||
state.on('project', () => this.refresh());
|
||
// A different project brings different objects – start folded again.
|
||
state.on('load', () => {
|
||
this.expanded.clear();
|
||
this.refresh();
|
||
});
|
||
state.on('selection', () => {
|
||
// The Container tab's picker only changes the selection, which swaps the holder under this
|
||
// panel; without the id check the title and rows would keep naming the previous one.
|
||
const swapped = (this.holder()?.id ?? '') !== this.shownHolderId;
|
||
if (this.revealSelection() || swapped) this.refresh();
|
||
else this.syncSelection();
|
||
});
|
||
this.refresh();
|
||
}
|
||
|
||
/** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */
|
||
private drop(draggedId: string, spot: DropSpot): void {
|
||
const holder = this.holder();
|
||
const object = holder && findObject(holder, draggedId);
|
||
if (!holder || !object) return;
|
||
if (spot.at === 'onto') {
|
||
if (object.parentId === spot.targetId) return;
|
||
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
|
||
this.expanded.add(spot.targetId);
|
||
this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId));
|
||
return;
|
||
}
|
||
let parentId = '';
|
||
let beforeId = '';
|
||
if (spot.at !== 'end') {
|
||
const target = findObject(holder, spot.targetId);
|
||
if (!target) return;
|
||
parentId = target.parentId;
|
||
const order = childIdsOf(holder, parentId);
|
||
beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? '');
|
||
}
|
||
if (beforeId === draggedId) return;
|
||
const siblings = childIdsOf(holder, parentId);
|
||
const rest = siblings.filter((id) => id !== draggedId);
|
||
const at = beforeId ? rest.indexOf(beforeId) : rest.length;
|
||
const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
|
||
if (object.parentId === parentId && wanted.join() === siblings.join()) return;
|
||
const commands: Command[] = [];
|
||
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
|
||
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
|
||
if (parentId) this.expanded.add(parentId);
|
||
this.state.execute(oneStep('Move object', commands));
|
||
}
|
||
|
||
/**
|
||
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
|
||
* active scene again.
|
||
*/
|
||
showObjectsOf(source: () => ObjectHolder | undefined): void {
|
||
this.source = source;
|
||
this.refresh();
|
||
}
|
||
|
||
/** With the content of placed containers resolved in – the rows show it like any other object. */
|
||
private holder(): ObjectHolder | undefined {
|
||
const holder = this.source();
|
||
return holder && resolvedHolder(this.state.project, holder);
|
||
}
|
||
|
||
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
|
||
rowOrder(): string[] {
|
||
const holder = this.holder();
|
||
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
|
||
}
|
||
|
||
/** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */
|
||
rowColor(id: string): string | null {
|
||
return this.list.rowColor(id);
|
||
}
|
||
|
||
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
|
||
chevronPosition(id: string): { x: number; y: number } | null {
|
||
return this.list.chevronGlobal(id);
|
||
}
|
||
|
||
/** Global centre of a row, away from its chevron (test hook). */
|
||
rowPosition(id: string): { x: number; y: number } | null {
|
||
return this.list.rowGlobal(id);
|
||
}
|
||
|
||
/** Global centre of the type icon at a row's right edge (test hook). */
|
||
iconPosition(id: string): { x: number; y: number } | null {
|
||
return this.list.iconGlobal(id);
|
||
}
|
||
|
||
expandAll(): void {
|
||
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
|
||
this.refresh();
|
||
}
|
||
|
||
collapseAll(): void {
|
||
this.expanded.clear();
|
||
// Keep the selected object reachable instead of folding it away under the user's hands.
|
||
this.revealSelection();
|
||
this.refresh();
|
||
}
|
||
|
||
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
|
||
const { state } = this;
|
||
const holder = this.holder();
|
||
if (!holder) return undefined;
|
||
const inScene = holder.type === 'scene';
|
||
const object = findObject(holder, id);
|
||
const asset = object && findAsset(state.project, object.assetId);
|
||
// Content of a placed container belongs to its template: values can be changed here, the
|
||
// structure cannot (see `model/instance.ts`).
|
||
const source = this.source();
|
||
const fromTemplate = Boolean(source && isTemplateContent(source, id));
|
||
const instance = Boolean(object && source && isLiveInstance(state.project, source, object));
|
||
// 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;
|
||
const menu = new Menu([
|
||
{
|
||
label: 'Select asset',
|
||
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
|
||
},
|
||
{
|
||
// The same as double-clicking the asset in the asset panel: selects it and opens its tab.
|
||
// A kind without an editor of its own would only be selected, so it is offered greyed out.
|
||
label: 'Open asset',
|
||
disabled: !asset || !hasAssetEditor(asset.type),
|
||
onSelect: () => object && state.openAsset(object.assetId),
|
||
},
|
||
{
|
||
label: 'Add collider',
|
||
disabled: !object || !source || many || !canAddCollider(state.project, source, object),
|
||
onSelect: () => addCollider(state, holder.id, id),
|
||
},
|
||
{
|
||
label: 'Add rigidbody',
|
||
disabled: !object || !source || many || !canAddRigidbody(state.project, source, object),
|
||
onSelect: () => addRigidbody(state, holder.id, id),
|
||
},
|
||
{
|
||
label: many ? `Create container from ${selected.length} objects` : 'Create container',
|
||
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
|
||
onSelect: () => createContainer(state, holder.id, selected),
|
||
},
|
||
{
|
||
label: 'Unpack container',
|
||
disabled: !instance,
|
||
onSelect: () => state.execute(new UnpackContainerCommand(holder.id, id)),
|
||
},
|
||
{
|
||
label: 'Unparent (move to top level)',
|
||
disabled: !object?.parentId || many || fromTemplate,
|
||
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
|
||
},
|
||
{ label: 'Expand all', onSelect: () => this.expandAll() },
|
||
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
|
||
{
|
||
label: many
|
||
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
|
||
: `Remove from ${inScene ? 'scene' : 'container'}`,
|
||
danger: true,
|
||
disabled: !source || selected.some((oid) => isTemplateContent(source, oid)),
|
||
onSelect: () => removeObjects(state, holder.id, selected),
|
||
},
|
||
]);
|
||
menu.open(x, y);
|
||
return menu;
|
||
}
|
||
|
||
private setExpanded(id: string, expand: boolean): void {
|
||
if (expand) this.expanded.add(id);
|
||
else this.expanded.delete(id);
|
||
this.refresh();
|
||
}
|
||
|
||
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
|
||
private revealSelection(): boolean {
|
||
const holder = this.holder();
|
||
if (!holder) return false;
|
||
let changed = false;
|
||
for (const id of this.state.selectedObjectIds) {
|
||
for (const ancestorId of ancestorIdsOf(holder, id)) {
|
||
if (this.expanded.has(ancestorId)) continue;
|
||
this.expanded.add(ancestorId);
|
||
changed = true;
|
||
}
|
||
}
|
||
return changed;
|
||
}
|
||
|
||
private refresh(): void {
|
||
const holder = this.holder();
|
||
this.shownHolderId = holder?.id ?? '';
|
||
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
|
||
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
|
||
// From the stored holder, not the resolved one: what an object follows depends on where it
|
||
// lives, and in a resolved view container content looks stored.
|
||
const source = this.source();
|
||
const marks = source ? deviationMarks(this.state.project, source) : new Set<string>();
|
||
this.list.setItems(
|
||
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
|
||
const asset = findAsset(this.state.project, object.assetId);
|
||
return {
|
||
id: object.id,
|
||
label: object.name || asset?.name || '?',
|
||
icon: asset && ASSET_ICON_SVG[asset.type],
|
||
iconAtEnd: true,
|
||
depth,
|
||
expanded: hasChildren ? this.expanded.has(object.id) : undefined,
|
||
overridden: marks.has(object.id),
|
||
};
|
||
}),
|
||
);
|
||
this.syncSelection();
|
||
this.scroll.relayoutContent();
|
||
this.scrollToSelection();
|
||
}
|
||
|
||
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 : []);
|
||
}
|
||
|
||
/** Keeps the primary selection in view after unfolding brought it back into the list. */
|
||
private scrollToSelection(): void {
|
||
const s = this.state.selection;
|
||
if (s?.kind !== 'object' || !s.objectId) return;
|
||
const index = this.rowOrder().indexOf(s.objectId);
|
||
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
|
||
}
|
||
}
|