Files
feuerwerk/src/editor/panels/HierarchyPanel.ts
T

289 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}