Files
feuerwerk/src/editor/panels/HierarchyPanel.ts
T
adminandClaude Opus 5.5 c95fbc167e Ping or open an object's asset from its hierarchy row (#353)
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>
2026-10-07 23:25:05 +02:00

349 lines
15 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 { 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);
}
}