diff --git a/README.md b/README.md index 50f5c8d3..793ca2f4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts new file mode 100644 index 00000000..36f8945b --- /dev/null +++ b/src/editor/objectMenu.ts @@ -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; +} diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index c63d5e65..f3e5cf57 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -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 { diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index 85617a46..019770d5 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -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; diff --git a/src/viewport/pick.ts b/src/viewport/pick.ts index 467be0cd..610ed16a 100644 --- a/src/viewport/pick.ts +++ b/src/viewport/pick.ts @@ -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)); +} diff --git a/tests/viewport/pick.test.ts b/tests/viewport/pick.test.ts index e08871d5..e262f92a 100644 --- a/tests/viewport/pick.test.ts +++ b/tests/viewport/pick.test.ts @@ -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(); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 82077a30..e2747db0 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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;