Right-click in the scene view opens the object menu (#204)

The object menu lived only on the hierarchy rows. Now a right-click in
the scene view opens the same menu for the object under the cursor.

- The menu moves out of HierarchyPanel into src/editor/objectMenu.ts.
  Hierarchy and scene view call the same function, so their entries
  cannot drift apart. Only the hierarchy adds "Expand all" and
  "Collapse all".
- Which object the menu is for follows the press rule from #203. A
  selected object under the cursor, even a hidden one, keeps the
  selection and the menu acts on all of it. Otherwise the topmost
  object is selected first.
- The rule is the pure function menuTargetOf in src/viewport/pick.ts,
  next to pressOn.
- Empty space, including an empty tilemap cell, opens no menu.
- The container tab gets the same, because ContainerCanvas extends the
  scene view.

Tests:
- ./check.sh: 142 files, 2094 tests, exit 0 (was 2090)
- e2e 39,40 fresh: ALL PASSED, 80 checks
- e2e 1-16,39,40 fresh: ALL PASSED, 565 checks
- New block 40r (3 checks): menu on a hidden selected crate, topmost
  object without a selection, no menu on an empty cell.
- Counter-check: a right-click that always takes the topmost object
  fails 2 unit tests and the hidden-crate e2e check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-22 19:47:31 +02:00
1 parent ad2a2d7921
commit 8c5846f956
7 files changed
+220 -77

No files matched your search

+91
View File
@@ -0,0 +1,91 @@
import { UnpackContainerCommand } from '../core/commands/containerCommands';
import { SetParentCommand } from '../core/commands/sceneObjectCommands';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
import { findAsset, type ObjectHolder } from '../core/model/Project';
import { Menu, type MenuItem } from '../ui';
import {
addCollider,
addRigidbody,
canAddCollider,
canAddRigidbody,
createContainer,
removeObjects,
} from './actions';
import { hasAssetEditor } from './assetTabs';
import type { EditorState } from './state';
/**
* The menu of a placed object, the same from the hierarchy and the scene view (#204). `source` is
* the stored holder, `holder` the one with container content resolved in; `extras` go above
* removing, where only the hierarchy has anything to add.
*/
export function openObjectMenu(
state: EditorState,
source: ObjectHolder,
holder: ObjectHolder,
id: string,
x: number,
y: number,
extras: readonly MenuItem[] = [],
): Menu {
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 fromTemplate = isTemplateContent(source, id);
const instance = Boolean(object && 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 || many || !canAddCollider(state.project, source, object),
onSelect: () => addCollider(state, holder.id, id),
},
{
label: 'Add rigidbody',
disabled: !object || many || !canAddRigidbody(state.project, source, object),
onSelect: () => addRigidbody(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
disabled: 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, '')),
},
...extras,
{
label: many
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
disabled: selected.some((oid) => isTemplateContent(source, oid)),
onSelect: () => removeObjects(state, holder.id, selected),
},
]);
menu.open(x, y);
return menu;
}