Files
feuerwerk/src/editor/panels/HierarchyPanel.ts
T
root ed1fe31c77 Count clicks ourselves so double-clicks work at all
Pixi only fills `detail` on the `click` event it synthesises; on
`pointerdown` it is always 0, so every `e.detail === 2` branch was dead
code. Asset rows, hierarchy folding and select-all in text fields never
responded to a double-click.

A shared click counter in src/ui/clicks.ts replaces the three readings of
`e.detail`. A finished double-click ends the run, so two in a row count
2 and 2 rather than 3 and 4.

The harness could only send `clickCount: 1`, which is why no test caught
this: every "activate" check went through Enter. It can send real
double-clicks now, and five checks drive the three paths separately.
2026-09-09 14:13:05 +02:00

281 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 { CompoundCommand } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
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 { resolveSceneObjectProps } from '../../core/properties/resolve';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there, into the gap above or below a row to order it among that row's
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
*
* A row's name turns gold when the object deviates from what it follows – its container template
* or its asset – or when anything below it does, so a folded subtree still shows it.
*
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
* project, and selecting an object always unfolds the way down to it.
*/
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 drop either puts the dragged row inside another one (parenting, which keeps the world
* position) or into a gap, which orders it among the siblings there – and does both when the
* gap belongs to a parent the object did not have. A move that would leave the order as it is
* does nothing, so a drag that lands where it started 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(commands.length === 1 ? commands[0]! : new CompoundCommand('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);
}
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 type = object && findAsset(state.project, object.assetId)?.type;
// 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 }),
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many || fromTemplate || instance,
onSelect: () => addCollider(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);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
id: object.id,
label: object.name || asset?.name || '?',
hint: asset?.type ?? '',
depth,
swatch: asset && hasColorSwatch(asset.type) ? Number(props.color ?? 0) : undefined,
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);
}
}