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:
1 parent
ad2a2d7921
commit
8c5846f956
7 files changed
+220
-77
No files matched your search
@@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1290 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1293 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DrawItem } from '../../src/viewport/items';
|
||||
import { pressOn, stackAt } from '../../src/viewport/pick';
|
||||
import { menuTargetOf, pressOn, stackAt } from '../../src/viewport/pick';
|
||||
|
||||
/** Only what the pick order reads: the type, the `z`, and a map's layers with their own `z`. */
|
||||
function item(
|
||||
@@ -101,3 +101,26 @@ describe('what a press picks (#203)', () => {
|
||||
expect(pressOn([], ['top'], 'top')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a right-click opens the menu for (#204)', () => {
|
||||
const stack = ['top', 'middle', 'bottom'];
|
||||
|
||||
it('selects the topmost first when nothing selected is under the pointer', () => {
|
||||
expect(menuTargetOf(stack, ['elsewhere'], 'elsewhere')).toEqual({ id: 'top', select: true });
|
||||
});
|
||||
|
||||
it('keeps a selection hidden under others and opens on it', () => {
|
||||
expect(menuTargetOf(stack, ['elsewhere', 'bottom'], 'elsewhere')).toEqual({
|
||||
id: 'bottom',
|
||||
select: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the primary one of a group', () => {
|
||||
expect(menuTargetOf(stack, ['top', 'middle'], 'middle')).toEqual({ id: 'middle', select: false });
|
||||
});
|
||||
|
||||
it('opens none on empty space', () => {
|
||||
expect(menuTargetOf([], ['top'], 'top')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -748,6 +748,66 @@ export async function tilemap(t) {
|
||||
JSON.stringify({ stackAbove, crateBefore, crateAfter, mapUnder, mapUnderAfter, pickedDrag }),
|
||||
);
|
||||
await undo();
|
||||
await settle();
|
||||
|
||||
// 40r. A right-click in the scene view opens the hierarchy's menu (#204), by the rule of a press:
|
||||
// the selected crate hidden under the map keeps the selection and gets the menu.
|
||||
const menuRows = async () => ((await json('__gm.menuRows()')) ?? []).filter(Boolean);
|
||||
const OBJECT_MENU = [
|
||||
'Select asset',
|
||||
'Open asset',
|
||||
'Add collider',
|
||||
'Add rigidbody',
|
||||
'Create container',
|
||||
'Unpack container',
|
||||
'Unparent (move to top level)',
|
||||
'Remove from scene',
|
||||
];
|
||||
const onHidden = await cellPoint(1, 1);
|
||||
await click(onHidden.x, onHidden.y, 'right');
|
||||
await settle();
|
||||
const hiddenRows = await menuRows();
|
||||
const hiddenPicked = await selected();
|
||||
await clickText('Select asset');
|
||||
await settle();
|
||||
const afterPick = await json('__gm.state.selection');
|
||||
check(
|
||||
'a right-click on a selected sprite under the map opens the object menu for the sprite',
|
||||
JSON.stringify(hiddenRows) === JSON.stringify(OBJECT_MENU) &&
|
||||
JSON.stringify(hiddenPicked) === JSON.stringify([CRATE]) &&
|
||||
afterPick?.kind === 'asset' &&
|
||||
afterPick.assetId === 'crate',
|
||||
JSON.stringify({ hiddenRows, hiddenPicked, afterPick }),
|
||||
);
|
||||
|
||||
// Nothing selected under the pointer: the topmost is selected first, the menu is for it.
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await settle();
|
||||
await click(onHidden.x, onHidden.y, 'right');
|
||||
await settle();
|
||||
const topRows = await menuRows();
|
||||
const topPicked = await selected();
|
||||
await press('Escape');
|
||||
await settle();
|
||||
check(
|
||||
'a right-click with nothing selected there selects the topmost object and opens its menu',
|
||||
JSON.stringify(topRows) === JSON.stringify(OBJECT_MENU) &&
|
||||
JSON.stringify(topPicked) === JSON.stringify([MAP_OBJECT]),
|
||||
JSON.stringify({ topRows, topPicked }),
|
||||
);
|
||||
|
||||
// Empty space opens no menu and leaves the selection alone.
|
||||
const emptyPoint = await cellPoint(COLUMNS - 1, ROWS - 1);
|
||||
await click(emptyPoint.x, emptyPoint.y, 'right');
|
||||
await settle();
|
||||
const emptyMenu = await json('__gm.menuRows()');
|
||||
const emptyPicked = await selected();
|
||||
check(
|
||||
'a right-click on an empty cell opens no menu and keeps the selection',
|
||||
emptyMenu === null && JSON.stringify(emptyPicked) === JSON.stringify([MAP_OBJECT]),
|
||||
JSON.stringify({ emptyMenu, emptyPicked }),
|
||||
);
|
||||
await press('Escape');
|
||||
await evaluate('__gm.state.select(null)');
|
||||
await mutate(`
|
||||
const scene = __gm.state.activeScene;
|
||||
|
||||
Reference in new issue
Block a user