import { oneStep } from '../../core/commands/CompoundCommand'; import { ancestorIdsOf, findGroup, findObject, groupIdOfRow, groupMemberIds, groupRowId, hierarchyRows, type HierarchyRow, } from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { groupName, objectName } from '../../core/model/SceneObject'; import { deviationMarks } from '../../core/properties/deviation'; import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; import { activateAsset } from '../actions'; import { groupRowItems, selectGroup } from '../objectGroups'; import { openObjectMenu } from '../objectMenu'; import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons'; import { hierarchyDrop } from './hierarchyDrop'; import { hierarchyRange } from './hierarchyRange'; 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. Groups (#241) are rows of their own over their members. 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 and group rows whose content is shown. Anything missing is collapsed. */ private readonly expanded = new Set(); /** * 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 = ''; /** Row the last click without Shift landed on – where a Shift-click range starts. */ private anchor: string | null = null; 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?.shift && this.selectRange(holder, id, modifiers.ctrl)) return; this.anchor = id; const groupId = id && groupIdOfRow(id); const toggle = modifiers && (modifiers.ctrl || modifiers.shift); if (groupId && toggle) this.toggleGroup(holder.id, groupId); else if (groupId) selectGroup(state, holder, groupId); else if (id && toggle) 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.activate(id); this.list.onReselect = (id) => { const assetId = this.assetOfRow(id); if (assetId) state.pingAsset(assetId); }; 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 row dragged out of the multi-selection takes the other selected objects along. 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(); if (!holder) return; const s = this.state.selection; const selected = s?.kind === 'object' && s.holderId === holder.id ? s.objectIds : []; const isOpen = (rowId: string) => this.expanded.has(rowId); const { commands, unfold } = hierarchyDrop(holder, draggedId, spot, isOpen, selected); if (commands.length === 0) return; // Unfold where the row went, otherwise it would seem to vanish on drop. for (const rowId of unfold) this.expanded.add(rowId); const many = selected.length > 1 && selected.includes(draggedId); this.state.execute(oneStep(many ? 'Move objects' : 'Move object', commands)); } /** * A double-click or Enter (#353): an object's row opens its asset, as a double-click on the asset * link in the inspector does. A group's row has no asset and folds instead. */ private activate(rowId: string): void { const assetId = this.assetOfRow(rowId); if (assetId) activateAsset(this.state, assetId); else if (groupIdOfRow(rowId)) this.setExpanded(rowId, !this.expanded.has(rowId)); } /** The asset an object's row was placed from – null for a group's row or a missing asset. */ private assetOfRow(rowId: string): string | null { const holder = this.holder(); const object = holder && !groupIdOfRow(rowId) ? findObject(holder, rowId) : undefined; return object && findAsset(this.state.project, object.assetId) ? object.assetId : null; } /** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */ private toggleGroup(holderId: string, groupId: string): void { const holder = this.holder(); if (!holder) return; const members = groupMemberIds(holder, groupId); const current = this.state.selectedObjectIds; const all = members.every((id) => current.includes(id)); const next = all ? current.filter((id) => !members.includes(id)) : [...new Set([...current, ...members])]; this.state.select(objectSelection(holderId, next)); } /** * Shift-click: the rows from the anchor to the clicked one replace the selection, with Ctrl * held they join it. The anchor stays, so a second Shift-click redraws the range from the same * row. Returns false when there is no anchor to start from – the click then toggles as before. */ private selectRange(holder: ObjectHolder, clickedRowId: string, add: boolean): boolean { const rows = this.rowOrder(); const anchor = this.anchorRow(holder, rows); if (!anchor) return false; const range = hierarchyRange(holder, rows, anchor, clickedRowId); if (range.length === 0) return false; const groupId = groupIdOfRow(clickedRowId); const primary = groupId ? groupMemberIds(holder, groupId)[0] : clickedRowId; const current = add ? this.state.selectedObjectIds : []; this.anchor = anchor; this.state.select(objectSelection(holder.id, [...new Set([...current, ...range])], primary)); return true; } /** * The remembered anchor while it is shown and still selected; otherwise the row of the primary * selection – which may come from the scene view – or its folded group's row. */ private anchorRow(holder: ObjectHolder, rows: readonly string[]): string | null { const selected = this.state.selectedObjectIds; const anchor = this.anchor; if (anchor && rows.includes(anchor)) { const groupId = groupIdOfRow(anchor); const ids = groupId ? groupMemberIds(holder, groupId) : [anchor]; if (ids.some((id) => selected.includes(id))) return anchor; } const s = this.state.selection; const primary = s?.kind === 'object' && s.holderId === holder.id ? s.objectId : null; if (!primary) return null; if (rows.includes(primary)) return primary; const groupId = findObject(holder, primary)?.groupId; return groupId && rows.includes(groupRowId(groupId)) ? groupRowId(groupId) : null; } /** * 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 ? hierarchyRows(holder, this.expanded).map((row) => this.rowId(row)) : []; } private rowId(row: HierarchyRow): string { return row.kind === 'group' ? groupRowId(row.group.id) : row.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); } /** Global centre of a row's hint – a group's member count (test hook). */ hintPosition(id: string): { x: number; y: number } | null { return this.list.hintGlobal(id); } expandAll(): void { const holder = this.holder(); for (const object of holder?.objects ?? []) this.expanded.add(object.id); for (const group of holder?.groups ?? []) this.expanded.add(groupRowId(group.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 source = this.source(); const holder = this.holder(); if (!source || !holder) return undefined; const folding = [ { label: 'Expand all', onSelect: () => this.expandAll() }, { label: 'Collapse all', onSelect: () => this.collapseAll() }, ]; const groupId = groupIdOfRow(id); if (!groupId) return openObjectMenu(this.state, source, holder, id, x, y, folding); const menu = new Menu(groupRowItems(this.state, source, groupId, folding)); 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 and the group of every selected object – but not a group whose members * are all selected: its own row shows that. Returns whether that changed anything. */ private revealSelection(): boolean { const holder = this.holder(); if (!holder) return false; const selected = this.state.selectedObjectIds; const unfold = new Set(); for (const id of selected) { const ancestors = ancestorIdsOf(holder, id); for (const ancestorId of ancestors) unfold.add(ancestorId); const top = findObject(holder, ancestors.at(-1) ?? id); if (!top?.groupId || !findGroup(holder, top.groupId)) continue; if (!groupMemberIds(holder, top.groupId).every((m) => selected.includes(m))) unfold.add(groupRowId(top.groupId)); } let changed = false; for (const rowId of unfold) { if (this.expanded.has(rowId)) continue; this.expanded.add(rowId); 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(); this.list.setItems( (holder ? hierarchyRows(holder, this.expanded) : []).map((row) => { if (row.kind === 'group') return { id: groupRowId(row.group.id), label: groupName(row.group), hint: String(row.memberIds.length), icon: GROUP_ICON_SVG, iconAtEnd: true, depth: 0, expanded: this.expanded.has(groupRowId(row.group.id)), overridden: row.memberIds.some((id) => marks.has(id)), }; const { object, depth, hasChildren } = row; const asset = findAsset(this.state.project, object.assetId); return { id: object.id, label: objectName(object, asset), 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(); } /** A group's row is lit when all its members are selected. */ private syncSelection(): void { const s = this.state.selection; const holder = this.holder(); const selected = s?.kind === 'object' ? s.objectIds : []; const groups = (holder?.groups ?? []).filter((g) => { const members = groupMemberIds(holder!, g.id); return members.length > 0 && members.every((id) => selected.includes(id)); }); this.list.select(s?.kind === 'object' ? s.objectId : null, true); this.list.highlight([...selected, ...groups.map((g) => groupRowId(g.id))]); } /** Keeps the primary selection – or its folded group's row – in view after unfolding. */ private scrollToSelection(): void { const s = this.state.selection; if (s?.kind !== 'object' || !s.objectId) return; const rows = this.rowOrder(); const top = this.holder() && findObject(this.holder()!, s.objectId); const index = rows.includes(s.objectId) ? rows.indexOf(s.objectId) : rows.indexOf(groupRowId(top?.groupId ?? '')); if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); } }