The asset link in the inspector already pings an asset on a click and opens it on a double-click. The rows of the Scene objects panel only selected and folded. Now they lead to the asset the same way. - A click on the row that is selected already pings the object's asset in the asset panel. The selection stays. The ping comes on release, so picking a row up to drag it pings nothing. - A double-click or Enter on an object's row opens its asset, selected or not. - ListView has a new onReselect for a plain click on the row that is selected alone. onSelect stays quiet then, as nothing changes. Decided without asking: - A double-click on an object's row no longer folds it. The chevron and the arrow keys still do. A group's row has no asset and keeps folding on a double-click. - A click with Ctrl or Shift, and one that narrows a multi-selection to its row, does not ping. Both change the selection. - A row whose asset is missing pings and opens nothing. Findings: - The double-click window goes by time (400 ms), not by the event's detail. Two quick click() calls from the harness are a double-click. The comment in e2e 01 said otherwise and is gone. Tests: - e2e 01 (melektaus-atx): ALL PASSED, 13 checks. The two checks for folding on a double-click became one for the ping and two for opening. Counter-check without the ping: that check red. - Gate (melektaus-atx): CHECK EXIT 0, 268 files, 4142 tests. - Not checked: Enter on a row, a group's row on a double-click. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
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<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 = '';
|
||
/** 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<string>();
|
||
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<string>();
|
||
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);
|
||
}
|
||
}
|