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;
}
+6 -72
View File
@@ -1,21 +1,12 @@
import type { Command } from '../../core/commands/Command';
import { oneStep } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { deviationMarks } from '../../core/properties/deviation';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import {
addCollider,
addRigidbody,
canAddCollider,
canAddRigidbody,
createContainer,
removeObjects,
} from '../actions';
import { hasAssetEditor } from '../assetTabs';
import { type DropSpot, ListView, type Menu, Panel, ScrollContainer } from '../../ui';
import { openObjectMenu } from '../objectMenu';
import { ASSET_ICON_SVG } from './assetIcons';
import { type EditorState, objectSelection } from '../state';
@@ -157,70 +148,13 @@ export class HierarchyPanel extends Panel {
}
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 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 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 }),
},
{
// 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 || !source || many || !canAddCollider(state.project, source, object),
onSelect: () => addCollider(state, holder.id, id),
},
{
label: 'Add rigidbody',
disabled: !object || !source || many || !canAddRigidbody(state.project, source, object),
onSelect: () => addRigidbody(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, '')),
},
const holder = this.holder();
if (!source || !holder) return undefined;
return openObjectMenu(this.state, source, holder, id, x, y, [
{ 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 {
+18 -1
View File
@@ -60,7 +60,8 @@ import {
} from '../../ui';
import { gripAt, withCornerAfter, withCornerMoved, withoutCorner } from '../polygonEdit';
import { collectHolderItems, type DrawItem } from '../../viewport/items';
import { type PointerHit, pressOn, stackAt } from '../../viewport/pick';
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick';
import { openObjectMenu } from '../objectMenu';
import { type LayerFocus, layerAlpha } from '../../viewport/tilemap';
import { CONE_LENGTH, dashedLine, markBlockingSide, placeNode } from '../../viewport/symbols';
import { drawSymbolIcon } from '../../viewport/symbolIcons';
@@ -701,6 +702,10 @@ export class SceneViewport extends FreeViewport {
* layer below a sprite does not put the map in front of it.
*/
protected override onItemPointerDown(_objectId: string, e: FederatedPointerEvent): void {
if (e.button === 2) {
this.openMenuUnder(e);
return;
}
if (e.button !== 0) return;
if (this.startCornerEdit(e)) return;
const holder = this.resolved();
@@ -743,6 +748,18 @@ export class SceneViewport extends FreeViewport {
};
}
/** The hierarchy's menu for what is under the pointer (#204); empty space opens none. */
private openMenuUnder(e: FederatedPointerEvent): void {
const source = this.holder();
const holder = this.resolved();
if (!source || !holder || this.dragging || this.stroke || this.corner || this.marquee) return;
const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId());
if (!target) return;
e.stopPropagation();
if (target.select) this.state.select(objectSelection(holder.id, [target.id]));
openObjectMenu(this.state, source, holder, target.id, e.global.x, e.global.y);
}
private primaryObjectId(): string | null {
const s = this.state.selection;
return s?.kind === 'object' ? s.objectId : null;
+20 -2
View File
@@ -44,9 +44,27 @@ export function pressOn(
primary: string | null,
): { select: string | null; onClick: string | null } | null {
if (stack.length === 0) return null;
const held =
primary !== null && stack.includes(primary) ? primary : stack.find((id) => selected.includes(id));
const held = heldIn(stack, selected, primary);
if (held === undefined) return { select: stack[0]!, onClick: null };
const next = stack[(stack.indexOf(held) + 1) % stack.length]!;
return { select: null, onClick: next === held ? null : next };
}
/**
* Which object a right-click on `stack` opens the menu for (#204), by the rule a press follows:
* a selected one under the pointer keeps the selection, otherwise the topmost is selected first.
*/
export function menuTargetOf(
stack: readonly string[],
selected: readonly string[],
primary: string | null,
): { id: string; select: boolean } | null {
if (stack.length === 0) return null;
const held = heldIn(stack, selected, primary);
return held === undefined ? { id: stack[0]!, select: true } : { id: held, select: false };
}
/** The selected object under the pointer, the primary one first; undefined when none is there. */
function heldIn(stack: readonly string[], selected: readonly string[], primary: string | null) {
return primary !== null && stack.includes(primary) ? primary : stack.find((id) => selected.includes(id));
}