diff --git a/README.md b/README.md index 4d3d3240..e2963fe1 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, 1432 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1442 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/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 777cc3c9..ff65d74a 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -91,6 +91,8 @@ export class CreateContainerCommand implements Command { parentId: isRoot ? '' : (templateIds.get(object.parentId) ?? ''), instanceEdits: { ...object.instanceEdits }, }; + // The template has no groups of its own. + delete template.groupId; const objectAsset = findAsset(project, object.assetId); if (isRoot && objectAsset) carryTransform( @@ -103,6 +105,10 @@ export class CreateContainerCommand implements Command { } const container = createSceneObject(this.objectId, this.assetId, containerLocal, containerParentId); + // Made of the members of one group, the container takes their place in it. + const rootGroups = new Set([...roots].map((id) => findObject(holder, id)!.groupId)); + const groupId = rootGroups.size === 1 ? [...rootGroups][0] : undefined; + if (!containerParentId && groupId) container.groupId = groupId; this.removed = removeSubtrees(holder, [...roots]); project.assets.push(asset); holder.objects.push(container); diff --git a/src/core/commands/groupCommands.ts b/src/core/commands/groupCommands.ts new file mode 100644 index 00000000..67c0e4ea --- /dev/null +++ b/src/core/commands/groupCommands.ts @@ -0,0 +1,137 @@ +import { newId } from '../ids'; +import { findGroup } from '../model/hierarchy'; +import type { ObjectHolder, Project } from '../model/Project'; +import type { ObjectGroup } from '../model/SceneObject'; +import { type Command, CommandError } from './Command'; +import { requireHolder, requireStructuralObject } from './require'; + +/** + * An edit of the hierarchy folders (#241). It only ever touches `groups` and the objects' + * `groupId`, so undo puts both back as they were. A group left without members is not listed and + * is dropped on the next load. + */ +abstract class GroupEditCommand implements Command { + abstract readonly label: string; + private groups: ObjectGroup[] | undefined; + private groupIds = new Map(); + + protected constructor(protected readonly holderId: string) {} + + protected abstract apply(holder: ObjectHolder, project: Project): void; + + execute(project: Project): void { + const holder = requireHolder(project, this.holderId); + this.groups = holder.groups?.map((g) => ({ ...g })); + this.groupIds = new Map(holder.objects.map((o) => [o.id, o.groupId])); + this.apply(holder, project); + } + + undo(project: Project): void { + const holder = requireHolder(project, this.holderId); + if (this.groups) holder.groups = this.groups.map((g) => ({ ...g })); + else delete holder.groups; + for (const object of holder.objects) { + const was = this.groupIds.get(object.id); + if (was === undefined) delete object.groupId; + else object.groupId = was; + } + } + + protected requireGroup(holder: ObjectHolder, groupId: string): ObjectGroup { + const group = findGroup(holder, groupId); + if (!group) throw new CommandError('That group no longer exists'); + return group; + } + + /** Puts the objects into the group; only top-level objects can go into one. */ + protected join( + project: Project, + holder: ObjectHolder, + objectIds: readonly string[], + groupId: string, + ): void { + const objects = objectIds.map((id) => requireStructuralObject(project, holder, id)); + if (objects.some((o) => o.parentId)) + throw new CommandError('Only top-level objects go into a group – a child stays under its parent'); + for (const object of objects) object.groupId = groupId; + } +} + +/** Makes a new group of top-level objects, taking them out of any group they were in. */ +export class GroupObjectsCommand extends GroupEditCommand { + readonly label = 'Group objects'; + readonly groupId = newId(); + + constructor( + holderId: string, + private readonly objectIds: readonly string[], + private readonly name: string, + ) { + super(holderId); + } + + protected override apply(holder: ObjectHolder, project: Project): void { + if (this.objectIds.length === 0) throw new CommandError('Select the objects to group first'); + this.join(project, holder, this.objectIds, this.groupId); + holder.groups = [...(holder.groups ?? []), { id: this.groupId, name: this.name }]; + } +} + +/** Moves objects into a group, or out of every group with an empty `groupId`. */ +export class SetObjectGroupCommand extends GroupEditCommand { + readonly label: string; + + constructor( + holderId: string, + private readonly objectIds: readonly string[], + private readonly groupId: string, + ) { + super(holderId); + this.label = groupId ? 'Move into group' : 'Remove from group'; + } + + protected override apply(holder: ObjectHolder, project: Project): void { + if (this.groupId) { + this.requireGroup(holder, this.groupId); + this.join(project, holder, this.objectIds, this.groupId); + return; + } + for (const id of this.objectIds) delete requireStructuralObject(project, holder, id).groupId; + } +} + +export class RenameGroupCommand extends GroupEditCommand { + readonly label = 'Rename group'; + + constructor( + holderId: string, + private readonly groupId: string, + private readonly name: string, + ) { + super(holderId); + } + + protected override apply(holder: ObjectHolder): void { + this.requireGroup(holder, this.groupId).name = this.name; + } +} + +/** Dissolves a group; its members stay where they are, at the top level. */ +export class UngroupCommand extends GroupEditCommand { + readonly label = 'Ungroup'; + + constructor( + holderId: string, + private readonly groupId: string, + ) { + super(holderId); + } + + protected override apply(holder: ObjectHolder): void { + this.requireGroup(holder, this.groupId); + for (const object of holder.objects) if (object.groupId === this.groupId) delete object.groupId; + const rest = (holder.groups ?? []).filter((g) => g.id !== this.groupId); + if (rest.length > 0) holder.groups = rest; + else delete holder.groups; + } +} diff --git a/src/core/commands/naming.ts b/src/core/commands/naming.ts index 27adef3a..a0c9f699 100644 --- a/src/core/commands/naming.ts +++ b/src/core/commands/naming.ts @@ -10,8 +10,16 @@ function firstFree(taken: ReadonlySet, candidate: (i: number) => string, /** "Sprite", "Sprite 2", "Sprite 3" … – the first name not yet used by an asset. */ export function nextAssetName(project: Project, base: string): string { - const taken = new Set(project.assets.map((a) => a.name)); - return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base; + return nextFreeName( + project.assets.map((a) => a.name), + base, + ); +} + +/** `base`, else "`base` 2", "`base` 3" … – the first name none of `taken` holds. */ +export function nextFreeName(taken: readonly string[], base: string): string { + const names = new Set(taken); + return names.has(base) ? firstFree(names, (i) => `${base} ${i}`, 2) : base; } /** "Layer 1", "Layer 2", … – the first numbered name none of `taken` holds. */ diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts index 18d56c88..b6924e6b 100644 --- a/src/core/commands/pasteCommands.ts +++ b/src/core/commands/pasteCommands.ts @@ -3,6 +3,7 @@ import { isTerminalType, terminalNode } from '../graph/nested'; import { nodeDef } from '../graph/NodeRegistry'; import { newId } from '../ids'; import type { GraphClip, ObjectClip } from '../model/clipboard'; +import { findGroup } from '../model/hierarchy'; import { isLiveInstance } from '../model/instance'; import { placementRejection } from '../model/placement'; import type { Vec2 } from '../model/Properties'; @@ -59,10 +60,13 @@ export class PasteObjectsCommand implements Command { const root = roots.get(object.id); if (!root) { copy.parentId = this.ids.get(object.parentId) ?? ''; + delete copy.groupId; return copy; } const parentId = this.parents[object.id] ?? ''; copy.parentId = parentId; + // The copy stays in its group when it lands at the top level of a holder that has the group. + if (parentId || !findGroup(holder, copy.groupId ?? '')) delete copy.groupId; copy.position = localPositionFor(project, holder, parentId, { x: root.world.x + this.shift.x, y: root.world.y + this.shift.y, diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 89a9bbbb..c42b9c8f 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -265,11 +265,12 @@ export class ReorderObjectCommand implements Command { /** * Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed - * for the new parent. + * for the new parent. Going under a parent takes it out of its group, which holds top-level + * objects only (#241). */ export class SetParentCommand implements Command { readonly label = 'Set parent'; - private previous: { parentId: string; position: Position; overrides: PropertyBag } = { + private previous: { parentId: string; position: Position; overrides: PropertyBag; groupId?: string } = { parentId: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, @@ -295,7 +296,9 @@ export class SetParentCommand implements Command { parentId: object.parentId, position: { ...object.position }, overrides: { ...object.overrides }, + groupId: object.groupId, }; + if (this.parentId) delete object.groupId; // The object stays where it is and how it looks: the new local position and, when the parent // chain's turn or size changed, transform rows that absorb the difference. const world = worldTransform(project, holder, object); @@ -315,5 +318,6 @@ export class SetParentCommand implements Command { object.parentId = this.previous.parentId; object.position = { ...this.previous.position }; object.overrides = { ...this.previous.overrides }; + if (this.previous.groupId !== undefined) object.groupId = this.previous.groupId; } } diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 56f92c91..17a6aa4b 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -3,7 +3,7 @@ import type { PropertyDef } from '../properties/PropertySchema'; import type { AssetType } from './AssetTypes'; import type { PropertyBag, PropertyValue, Vec2 } from './Properties'; import type { Scope } from './Scope'; -import type { SceneObject } from './SceneObject'; +import type { ObjectGroup, SceneObject } from './SceneObject'; export interface AssetBase { id: string; @@ -27,6 +27,8 @@ export interface TextAsset extends AssetBase { export interface SceneAsset extends AssetBase { type: 'scene'; objects: SceneObject[]; + /** Hierarchy folders (#241); absent when there are none. */ + groups?: ObjectGroup[]; } /** @@ -96,6 +98,8 @@ export interface RigidbodyAsset extends AssetBase { export interface ContainerAsset extends AssetBase { type: 'container'; objects: SceneObject[]; + /** Hierarchy folders of the template's top level (#241); absent when there are none. */ + groups?: ObjectGroup[]; } /** How the parts between the corners of a nine-slice frame fill the space they are given (#41). */ diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts index f098c36d..a2486208 100644 --- a/src/core/model/SceneObject.ts +++ b/src/core/model/SceneObject.ts @@ -29,6 +29,17 @@ export interface SceneObject { * stored – it follows the template. */ instanceEdits: Record; + /** + * Folder of the hierarchy this object is listed in (#241) – top-level objects only. It changes + * nothing about the tree: the game sees it only as the order equal `z` is drawn in. + */ + groupId?: string; +} + +/** A folder in the hierarchy for top-level objects – editor looks only, never part of the tree. */ +export interface ObjectGroup { + id: string; + name: string; } export function createSceneObject( diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index 205addd4..df39f125 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -1,5 +1,5 @@ import { type Removed, removeWhere } from '../util/list'; -import type { SceneObject } from './SceneObject'; +import type { ObjectGroup, SceneObject } from './SceneObject'; import { descendantsBelow, parentChain } from './walk'; /** @@ -8,6 +8,8 @@ import { descendantsBelow, parentChain } from './walk'; */ export interface ObjectTree { objects: SceneObject[]; + /** Hierarchy folders for top-level objects (#241) – they order the tree but never shape it. */ + groups?: ObjectGroup[]; } export function findObject(tree: ObjectTree, id: string): SceneObject | undefined { @@ -83,14 +85,37 @@ export interface TreeEntry { } /** - * Top-level objects in storage order, each followed by its subtree – the order the hierarchy panel - * shows and the tie-break order for equal `z`. + * The stored list with the members of each group moved up behind the first of them: a group is + * listed – and drawn at equal `z` – as one block wherever its first member stands (#241). + */ +export function groupedOrder(objects: readonly SceneObject[]): readonly SceneObject[] { + const blocks = new Map(); + for (const object of objects) { + if (object.parentId || !object.groupId) continue; + const block = blocks.get(object.groupId); + if (block) block.push(object); + else blocks.set(object.groupId, [object]); + } + if (blocks.size === 0) return objects; + const result: SceneObject[] = []; + for (const object of objects) { + const block = object.parentId || !object.groupId ? undefined : blocks.get(object.groupId); + if (!block) result.push(object); + else if (block[0] === object) result.push(...block); + } + return result; +} + +/** + * Top-level objects in storage order, groups kept together, each followed by its subtree – the + * order the hierarchy panel shows and the tie-break order for equal `z`. */ export function treeOrder(tree: ObjectTree): TreeEntry[] { const result: TreeEntry[] = []; + const ordered = groupedOrder(tree.objects); const ids = new Set(tree.objects.map((o) => o.id)); const visit = (parentId: string, depth: number) => { - for (const object of tree.objects) { + for (const object of ordered) { if (object.parentId !== parentId) continue; result.push({ object, depth }); visit(object.id, depth + 1); @@ -98,34 +123,74 @@ export function treeOrder(tree: ObjectTree): TreeEntry[] { }; visit('', 0); // Objects whose parent is missing are listed at the top level rather than dropped. - for (const object of tree.objects) { + for (const object of ordered) { if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 }); } return result; } -export interface VisibleEntry extends TreeEntry { - hasChildren: boolean; +export function findGroup(tree: ObjectTree, groupId: string): ObjectGroup | undefined { + return tree.groups?.find((g) => g.id === groupId); } +/** The top-level objects listed in the group, in tree order. */ +export function groupMemberIds(tree: ObjectTree, groupId: string): string[] { + return groupedOrder(tree.objects) + .filter((o) => !o.parentId && o.groupId === groupId) + .map((o) => o.id); +} + +/** Only top-level objects go into a group – a child is listed under its parent. */ +export function canGroup(tree: ObjectTree, ids: readonly string[]): boolean { + return ids.length > 0 && ids.every((id) => findObject(tree, id)?.parentId === ''); +} + +/** The list id of a group's row: never an object id, which has no colon. */ +export function groupRowId(groupId: string): string { + return `group:${groupId}`; +} + +export function groupIdOfRow(rowId: string): string | null { + return rowId.startsWith('group:') ? rowId.slice('group:'.length) : null; +} + +export type HierarchyRow = + | { kind: 'object'; object: SceneObject; depth: number; hasChildren: boolean } + | { kind: 'group'; group: ObjectGroup; memberIds: string[]; depth: number }; + /** - * Tree order with the subtrees of collapsed objects left out. Anything not in `expanded` counts as - * collapsed, so a freshly loaded scene shows only its top level. + * Tree order with a row in front of each group and the subtrees of collapsed objects and groups + * left out. `expanded` holds object ids and group row ids; anything not in it counts as collapsed, + * so a freshly loaded scene shows only its top level. */ -export function visibleTreeOrder(tree: ObjectTree, expanded: ReadonlySet): VisibleEntry[] { +export function hierarchyRows(tree: ObjectTree, expanded: ReadonlySet): HierarchyRow[] { const parents = new Set(tree.objects.map((o) => o.parentId).filter(Boolean)); - const result: VisibleEntry[] = []; + const groups = new Map((tree.groups ?? []).map((g) => [g.id, g])); + const result: HierarchyRow[] = []; // Depth of the collapsed row whose subtree is being skipped (-1 = showing rows). let collapsedAt = -1; - for (const entry of treeOrder(tree)) { + // The group of the top-level object the current entries belong to, and whether it is folded. + let group: ObjectGroup | undefined; + let groupFolded = false; + for (const { object, depth } of treeOrder(tree)) { + if (depth === 0) { + const next = object.parentId ? undefined : groups.get(object.groupId ?? ''); + if (next && next !== group) { + const rowId = groupRowId(next.id); + result.push({ kind: 'group', group: next, memberIds: groupMemberIds(tree, next.id), depth: 0 }); + groupFolded = !expanded.has(rowId); + } + group = next; + } + if (group && groupFolded) continue; if (collapsedAt >= 0) { - if (entry.depth > collapsedAt) continue; + if (depth > collapsedAt) continue; collapsedAt = -1; } - const hasChildren = parents.has(entry.object.id); - result.push({ ...entry, hasChildren }); - if (hasChildren && !expanded.has(entry.object.id)) collapsedAt = entry.depth; + const hasChildren = parents.has(object.id); + result.push({ kind: 'object', object, depth: depth + (group ? 1 : 0), hasChildren }); + if (hasChildren && !expanded.has(object.id)) collapsedAt = depth; } return result; } diff --git a/src/core/model/instance.ts b/src/core/model/instance.ts index dcb473e9..2911d290 100644 --- a/src/core/model/instance.ts +++ b/src/core/model/instance.ts @@ -3,7 +3,7 @@ * in sparse `instanceEdits` keyed by template-id path; derived ids `~` are never stored. */ import type { ContainerAsset } from './Asset'; -import type { ObjectTree } from './hierarchy'; +import { groupedOrder, type ObjectTree } from './hierarchy'; import { type Axis, type Position, @@ -147,7 +147,8 @@ function expand( depth: number, out: SceneObject[], ): void { - for (const template of asset.objects) { + // In the order the template lists its top level, groups together, so equal `z` ties the same way. + for (const template of groupedOrder(asset.objects)) { const path = prefix ? `${prefix}${PATH_SEP}${template.id}` : template.id; const id = instanceChildId(instanceId, path); const parentId = template.parentId diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index c07e83ba..742eb16f 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -24,8 +24,10 @@ import type { AnimationTrack, Asset, BehaviorAsset, + ContainerAsset, PixelFrame, PixelLayer, + SceneAsset, StateMachineAsset, TextureFrame, GradientStop, @@ -51,7 +53,7 @@ import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance'; import { type PropertyBag, type PropertyValue, tagOf, type Vec2 } from '../model/Properties'; import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project'; import { readScope, readSharedScope } from '../model/Scope'; -import type { SceneObject } from '../model/SceneObject'; +import type { ObjectGroup, SceneObject } from '../model/SceneObject'; import { DEFAULT_FILL, isTextureFile, @@ -158,7 +160,15 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project { const name = typeof a.name === 'string' ? a.name : type; const props = normalizeBag(a.props, schema, false); if (type === 'scene' || type === 'container') { - assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemaOf) }); + const holder: SceneAsset | ContainerAsset = { + id, + type, + name, + props, + objects: normalizeObjects(a.objects, schemaOf), + }; + normalizeGroups(holder, a.groups); + assets.push(holder); } else if (type === 'behavior') { const declared = variables.get(id) ?? []; const behavior: BehaviorAsset = { @@ -297,6 +307,7 @@ function normalizeObjects( overrides: normalizeBag(o.overrides, schema, true), parentId: typeof o.parentId === 'string' ? o.parentId : '', instanceEdits: normalizeInstanceEdits(o.instanceEdits), + ...(typeof o.groupId === 'string' && o.groupId ? { groupId: o.groupId } : {}), }); } // Parents must exist in the same scene, differ from the object itself and not form cycles. @@ -308,6 +319,27 @@ function normalizeObjects( return objects; } +/** + * Hierarchy folders (#241): only those with a member are kept, and a member is a top-level object + * naming one of them – anything else loses its `groupId`. + */ +function normalizeGroups(holder: SceneAsset | ContainerAsset, raw: unknown): void { + const groups = new Map(); + for (const item of Array.isArray(raw) ? raw : []) { + const g = asRecord(item); + if (typeof g.id !== 'string' || !g.id || groups.has(g.id)) continue; + groups.set(g.id, { id: g.id, name: typeof g.name === 'string' ? g.name : '' }); + } + const used = new Set(); + for (const object of holder.objects) { + if (object.groupId === undefined) continue; + if (!object.parentId && groups.has(object.groupId)) used.add(object.groupId); + else delete object.groupId; + } + const kept = [...groups.values()].filter((g) => used.has(g.id)); + if (kept.length > 0) holder.groups = kept; +} + /** * Deviations of container content, keyed by template path. Values are not coerced against a schema: * which asset a path leads to depends on container assets that may not be built yet, and resolving diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index 06825f97..af74ac3b 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -2,6 +2,7 @@ import type { AssetType } from '../../core/model/AssetTypes'; import { Button, Label, Panel, TextField, Widget, activeDrag, currentMenu, ui } from '../../ui'; import { Theme } from '../../ui/Theme'; import { currentFontPicker } from '../font/FontPicker'; +import { currentGroupDialog } from '../panels/GroupNameDialog'; import { currentSoundPicker } from '../sound/SoundPicker'; import { rectOf, textProbe } from '../textProbe'; import { currentImagePicker } from '../texture/ImagePicker'; @@ -299,5 +300,7 @@ export function uiHooks({ app, state, editor }: HookContext) { hierarchyIcon: (id: string) => hierarchy.iconPosition(id), hierarchyExpandAll: () => hierarchy.expandAll(), hierarchyCollapseAll: () => hierarchy.collapseAll(), + /** Whether the dialog naming a hierarchy group is open (#241). */ + groupDialogOpen: () => currentGroupDialog() !== null, }; } diff --git a/src/editor/objectGroups.ts b/src/editor/objectGroups.ts new file mode 100644 index 00000000..e208ff24 --- /dev/null +++ b/src/editor/objectGroups.ts @@ -0,0 +1,92 @@ +import { + GroupObjectsCommand, + RenameGroupCommand, + SetObjectGroupCommand, + UngroupCommand, +} from '../core/commands/groupCommands'; +import { nextFreeName } from '../core/commands/naming'; +import { canGroup, findGroup, findObject, groupMemberIds } from '../core/model/hierarchy'; +import type { ObjectHolder } from '../core/model/Project'; +import type { MenuEntry, MenuItem } from '../ui'; +import { GroupNameDialog } from './panels/GroupNameDialog'; +import { type EditorState, objectSelection } from './state'; + +/** "Group", "Group 2", … – free among the groups that are listed. */ +function nextGroupName(holder: ObjectHolder): string { + const listed = (holder.groups ?? []).filter((g) => groupMemberIds(holder, g.id).length > 0); + return nextFreeName( + listed.map((g) => g.name), + 'Group', + ); +} + +/** + * The object menu's entries for groups (#241). `source` is the stored holder: content of a placed + * container is a child, so it can never be grouped. + */ +export function groupMenuItems( + state: EditorState, + source: ObjectHolder, + selected: readonly string[], +): MenuItem[] { + const many = selected.length > 1; + const grouped = selected.filter((id) => findGroup(source, findObject(source, id)?.groupId ?? '')); + return [ + { + label: many ? `Group ${selected.length} objects…` : 'Group…', + disabled: !canGroup(source, selected), + onSelect: () => groupObjects(state, source, selected), + }, + { + label: 'Remove from group', + disabled: grouped.length === 0, + onSelect: () => state.execute(new SetObjectGroupCommand(source.id, grouped, '')), + }, + ]; +} + +export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void { + const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object'; + GroupNameDialog.open( + new GroupNameDialog(title, nextGroupName(source), 'Group', (name) => { + state.execute(new GroupObjectsCommand(source.id, ids, name)); + }), + ); +} + +/** The menu of a group's row in the hierarchy; `extras` go below, where the panel adds folding. */ +export function groupRowItems( + state: EditorState, + source: ObjectHolder, + groupId: string, + extras: readonly MenuItem[], +): MenuEntry[] { + const group = findGroup(source, groupId); + if (!group) return [...extras]; + return [ + { + label: 'Select members', + onSelect: () => selectGroup(state, source, groupId), + }, + { + label: 'Rename…', + onSelect: () => + GroupNameDialog.open( + new GroupNameDialog(`Rename group "${group.name}"`, group.name, 'Rename', (name) => { + if (name !== group.name) state.execute(new RenameGroupCommand(source.id, groupId, name)); + }), + ), + }, + { + label: 'Ungroup', + onSelect: () => state.execute(new UngroupCommand(source.id, groupId)), + }, + { separator: true }, + ...extras, + ]; +} + +export function selectGroup(state: EditorState, source: ObjectHolder, groupId: string): void { + const members = groupMemberIds(source, groupId); + state.select(objectSelection(source.id, members, members[0])); +} diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index 4c4d8940..b6e4fd12 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -20,6 +20,7 @@ import { } from './actions'; import { hasAssetEditor } from './assetTabs'; import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard'; +import { groupMenuItems } from './objectGroups'; import type { EditorState } from './state'; /** @@ -112,6 +113,7 @@ export function openObjectMenu( disabled: !object?.parentId || many || fromTemplate, onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')), }, + ...groupMenuItems(state, source, selected), ...extras, { label: many diff --git a/src/editor/panels/GroupNameDialog.ts b/src/editor/panels/GroupNameDialog.ts new file mode 100644 index 00000000..351cebfc --- /dev/null +++ b/src/editor/panels/GroupNameDialog.ts @@ -0,0 +1,38 @@ +import { TextField, ui } from '../../ui'; +import { dialogSlot, FormDialog } from '../FormDialog'; + +const FIELD_WIDTH = 220; + +const slot = dialogSlot(); + +/** The open group name dialog, for the check scripts. */ +export const currentGroupDialog = slot.current; + +/** + * Names a hierarchy group (#241) – a new one before it is made, or an existing one. The name is + * picked up with the caret already in it, so typing replaces it straight away. + */ +export class GroupNameDialog extends FormDialog { + private readonly field: TextField; + + constructor(title: string, name: string, confirm: string, onName: (name: string) => void) { + super(320, title); + const accept = () => onName(this.field.value.trim() || name); + this.field = new TextField(name, { + width: FIELD_WIDTH, + onSubmit: () => { + this.close(); + accept(); + }, + }); + this.content.add(this.formRow(this.fieldLabel('Name'), this.field)); + this.addButtons(confirm, 'primary', accept); + } + + /** Opens the dialog centred with the name selected. */ + static open(dialog: GroupNameDialog): GroupNameDialog { + slot.open(dialog); + ui().focus.focus(dialog.field); + return dialog; + } +} diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index f3e5cf57..8f693f35 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -1,23 +1,33 @@ -import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; -import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; -import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy'; +import { + ancestorIdsOf, + findGroup, + findObject, + groupIdOfRow, + groupMemberIds, + groupRowId, + hierarchyRows, + type HierarchyRow, +} from '../../core/model/hierarchy'; import { resolvedHolder } from '../../core/model/instance'; import { findAsset, type ObjectHolder } from '../../core/model/Project'; import { deviationMarks } from '../../core/properties/deviation'; -import { type DropSpot, ListView, type Menu, Panel, ScrollContainer } from '../../ui'; +import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui'; +import { groupRowItems, selectGroup } from '../objectGroups'; import { openObjectMenu } from '../objectMenu'; -import { ASSET_ICON_SVG } from './assetIcons'; +import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons'; +import { hierarchyDrop } from './hierarchyDrop'; import { type EditorState, objectSelection } from '../state'; /** * Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always - * keeps world position. Fold state is view state kept here, never in the project. + * keeps world position. Groups (#241) are rows of their own over their members. Fold state is view + * state kept here, never in the project. */ 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. */ + /** Ids of the objects and group rows whose content is shown. Anything missing is collapsed. */ private readonly expanded = new Set(); /** * Which objects are listed. A function, not an id, so a container renamed or swapped under the @@ -34,7 +44,11 @@ export class HierarchyPanel extends Panel { this.list.onSelect = (id, modifiers) => { const holder = this.holder(); if (!holder) return; - if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id); + const groupId = id && groupIdOfRow(id); + const toggle = modifiers && (modifiers.ctrl || modifiers.shift); + if (groupId && toggle) this.toggleGroup(holder.id, groupId); + else if (groupId) selectGroup(state, holder, groupId); + else if (id && toggle) state.toggleObject(holder.id, id); else state.select(id ? objectSelection(holder.id, [id]) : null); }; this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot); @@ -63,37 +77,25 @@ export class HierarchyPanel extends Panel { /** A move that leaves the order as it is does nothing, so a drag back to its start 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); + if (!holder) return; + const { commands, unfold } = hierarchyDrop(holder, draggedId, spot, (rowId) => this.expanded.has(rowId)); + if (commands.length === 0) return; + // Unfold where the row went, otherwise it would seem to vanish on drop. + for (const rowId of unfold) this.expanded.add(rowId); this.state.execute(oneStep('Move object', commands)); } + /** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */ + private toggleGroup(holderId: string, groupId: string): void { + const holder = this.holder(); + if (!holder) return; + const members = groupMemberIds(holder, groupId); + const current = this.state.selectedObjectIds; + const all = members.every((id) => current.includes(id)); + const next = all ? current.filter((id) => !members.includes(id)) : [...new Set([...current, ...members])]; + this.state.select(objectSelection(holderId, next)); + } + /** * Lists another holder's objects – the Container tab hands in its template, the Scene tab the * active scene again. @@ -112,7 +114,11 @@ export class HierarchyPanel extends Panel { /** 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) : []; + return holder ? hierarchyRows(holder, this.expanded).map((row) => this.rowId(row)) : []; + } + + private rowId(row: HierarchyRow): string { + return row.kind === 'group' ? groupRowId(row.group.id) : row.object.id; } /** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */ @@ -136,7 +142,9 @@ export class HierarchyPanel extends Panel { } expandAll(): void { - for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id); + const holder = this.holder(); + for (const object of holder?.objects ?? []) this.expanded.add(object.id); + for (const group of holder?.groups ?? []) this.expanded.add(groupRowId(group.id)); this.refresh(); } @@ -151,10 +159,15 @@ export class HierarchyPanel extends Panel { const source = this.source(); const holder = this.holder(); if (!source || !holder) return undefined; - return openObjectMenu(this.state, source, holder, id, x, y, [ + const folding = [ { label: 'Expand all', onSelect: () => this.expandAll() }, { label: 'Collapse all', onSelect: () => this.collapseAll() }, - ]); + ]; + const groupId = groupIdOfRow(id); + if (!groupId) return openObjectMenu(this.state, source, holder, id, x, y, folding); + const menu = new Menu(groupRowItems(this.state, source, groupId, folding)); + menu.open(x, y); + return menu; } private setExpanded(id: string, expand: boolean): void { @@ -163,17 +176,28 @@ export class HierarchyPanel extends Panel { this.refresh(); } - /** Unfolds the ancestors of every selected object. Returns whether that changed anything. */ + /** + * Unfolds the ancestors and the group of every selected object – but not a group whose members + * are all selected: its own row shows that. Returns whether that changed anything. + */ private revealSelection(): boolean { const holder = this.holder(); if (!holder) return false; + const selected = this.state.selectedObjectIds; + const unfold = new Set(); + for (const id of selected) { + const ancestors = ancestorIdsOf(holder, id); + for (const ancestorId of ancestors) unfold.add(ancestorId); + const top = findObject(holder, ancestors.at(-1) ?? id); + if (!top?.groupId || !findGroup(holder, top.groupId)) continue; + if (!groupMemberIds(holder, top.groupId).every((m) => selected.includes(m))) + unfold.add(groupRowId(top.groupId)); + } 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; - } + for (const rowId of unfold) { + if (this.expanded.has(rowId)) continue; + this.expanded.add(rowId); + changed = true; } return changed; } @@ -188,7 +212,18 @@ export class HierarchyPanel extends Panel { const source = this.source(); const marks = source ? deviationMarks(this.state.project, source) : new Set(); this.list.setItems( - (holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => { + (holder ? hierarchyRows(holder, this.expanded) : []).map((row) => { + if (row.kind === 'group') + return { + id: groupRowId(row.group.id), + label: row.group.name || 'Group', + hint: String(row.memberIds.length), + icon: GROUP_ICON_SVG, + depth: 0, + expanded: this.expanded.has(groupRowId(row.group.id)), + overridden: row.memberIds.some((id) => marks.has(id)), + }; + const { object, depth, hasChildren } = row; const asset = findAsset(this.state.project, object.assetId); return { id: object.id, @@ -206,17 +241,28 @@ export class HierarchyPanel extends Panel { this.scrollToSelection(); } + /** A group's row is lit when all its members are selected. */ private syncSelection(): void { const s = this.state.selection; + const holder = this.holder(); + const selected = s?.kind === 'object' ? s.objectIds : []; + const groups = (holder?.groups ?? []).filter((g) => { + const members = groupMemberIds(holder!, g.id); + return members.length > 0 && members.every((id) => selected.includes(id)); + }); this.list.select(s?.kind === 'object' ? s.objectId : null, true); - this.list.highlight(s?.kind === 'object' ? s.objectIds : []); + this.list.highlight([...selected, ...groups.map((g) => groupRowId(g.id))]); } - /** Keeps the primary selection in view after unfolding brought it back into the list. */ + /** Keeps the primary selection – or its folded group's row – in view after unfolding. */ private scrollToSelection(): void { const s = this.state.selection; if (s?.kind !== 'object' || !s.objectId) return; - const index = this.rowOrder().indexOf(s.objectId); + const rows = this.rowOrder(); + const top = this.holder() && findObject(this.holder()!, s.objectId); + const index = rows.includes(s.objectId) + ? rows.indexOf(s.objectId) + : rows.indexOf(groupRowId(top?.groupId ?? '')); if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); } } diff --git a/src/editor/panels/assetIcons.ts b/src/editor/panels/assetIcons.ts index 220dd5be..4759882d 100644 --- a/src/editor/panels/assetIcons.ts +++ b/src/editor/panels/assetIcons.ts @@ -10,6 +10,7 @@ import collider from './icons/collider.svg?raw'; import container from './icons/container.svg?raw'; import font from './icons/font.svg?raw'; import game from './icons/game.svg?raw'; +import group from './icons/group.svg?raw'; import list from './icons/list.svg?raw'; import palette from './icons/palette.svg?raw'; import particles from './icons/particles.svg?raw'; @@ -56,6 +57,9 @@ export const ASSET_ICON_SVG: Record = { /** The List tab's: it edits a list value, which is no asset. */ export const LIST_ICON_SVG = list; +/** A group's row in the hierarchy (#241): a folder, as it only sorts what is listed. */ +export const GROUP_ICON_SVG = group; + /** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */ export const GAME_ICON_SVG = game; diff --git a/src/editor/panels/hierarchyDrop.ts b/src/editor/panels/hierarchyDrop.ts new file mode 100644 index 00000000..9e2aab09 --- /dev/null +++ b/src/editor/panels/hierarchyDrop.ts @@ -0,0 +1,127 @@ +import type { Command } from '../../core/commands/Command'; +import { SetObjectGroupCommand } from '../../core/commands/groupCommands'; +import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands'; +import { + childIdsOf, + findGroup, + findObject, + groupIdOfRow, + groupMemberIds, + groupRowId, + type ObjectTree, + treeOrder, +} from '../../core/model/hierarchy'; +import type { DropSpot } from '../../ui/widgets/ListView'; + +export interface HierarchyDrop { + /** Empty when the drop changes nothing – a drag back to its start costs no undo step. */ + commands: Command[]; + /** Rows to unfold so the dropped row stays in sight. */ + unfold: string[]; +} + +const NOTHING: HierarchyDrop = { commands: [], unfold: [] }; + +/** + * What dropping a hierarchy row does. `isOpen` tells whether a row is unfolded: the gap below an + * open group's row is inside the group, below a folded one it is behind the group. + */ +export function hierarchyDrop( + holder: ObjectTree & { id: string }, + draggedId: string, + spot: DropSpot, + isOpen: (rowId: string) => boolean, +): HierarchyDrop { + const groupId = groupIdOfRow(draggedId); + return groupId ? moveGroup(holder, groupId, spot) : moveObject(holder, draggedId, spot, isOpen); +} + +/** + * Where a dropped object goes: its parent, its group and the object it is stored in front of. A + * group lists its members in stored order and stands where its first member does, so an object is + * put in front of the one it should be listed before – or, joining a group behind its last member, + * at the very end. + */ +function moveObject( + holder: ObjectTree & { id: string }, + draggedId: string, + spot: DropSpot, + isOpen: (rowId: string) => boolean, +): HierarchyDrop { + const object = findObject(holder, draggedId); + if (!object) return NOTHING; + const inGroup = !object.parentId && findGroup(holder, object.groupId ?? '') ? object.groupId! : ''; + let parentId = ''; + let groupId = ''; + let beforeId = ''; + const targetGroup = spot.at === 'end' ? null : groupIdOfRow(spot.targetId); + const target = spot.at === 'end' || targetGroup ? undefined : findObject(holder, spot.targetId); + if (spot.at === 'onto' && target) { + if (object.parentId === target.id) return NOTHING; + return { commands: [new SetParentCommand(holder.id, draggedId, target.id)], unfold: [target.id] }; + } + if (targetGroup) { + const members = groupMemberIds(holder, targetGroup); + if (spot.at === 'onto' && inGroup === targetGroup) return NOTHING; + if (spot.at === 'onto' || (spot.at === 'after' && isOpen(groupRowId(targetGroup)))) { + groupId = targetGroup; + beforeId = spot.at === 'onto' ? '' : (members[0] ?? ''); + } else { + beforeId = spot.at === 'before' ? (members[0] ?? '') : topLevelAfter(holder, members.at(-1) ?? ''); + } + } else if (target) { + parentId = target.parentId; + groupId = !parentId && findGroup(holder, target.groupId ?? '') ? target.groupId! : ''; + if (spot.at === 'before') beforeId = target.id; + else if (groupId) { + const members = groupMemberIds(holder, groupId); + beforeId = members[members.indexOf(target.id) + 1] ?? ''; + } else if (parentId) { + const order = childIdsOf(holder, parentId); + beforeId = order[order.indexOf(target.id) + 1] ?? ''; + } else beforeId = topLevelAfter(holder, target.id); + } + const commands: Command[] = []; + if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId)); + if (!parentId && inGroup !== groupId) + commands.push(new SetObjectGroupCommand(holder.id, [draggedId], groupId)); + if (beforeId !== draggedId && !standsBefore(holder, draggedId, parentId, beforeId)) + commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId)); + const unfold = [...(parentId ? [parentId] : []), ...(groupId ? [groupRowId(groupId)] : [])]; + return { commands, unfold }; +} + +/** A group row goes between top-level rows as one block; it never goes inside anything. */ +function moveGroup(holder: ObjectTree & { id: string }, groupId: string, spot: DropSpot): HierarchyDrop { + const members = groupMemberIds(holder, groupId); + let beforeId = ''; + if (spot.at !== 'end') { + if (spot.at === 'onto') return NOTHING; + const otherGroup = groupIdOfRow(spot.targetId); + const target = otherGroup ? undefined : findObject(holder, spot.targetId); + if (otherGroup) { + const theirs = groupMemberIds(holder, otherGroup); + beforeId = spot.at === 'before' ? (theirs[0] ?? '') : topLevelAfter(holder, theirs.at(-1) ?? ''); + } else if (target && !target.parentId && !findGroup(holder, target.groupId ?? '')) { + beforeId = spot.at === 'before' ? target.id : topLevelAfter(holder, target.id); + } else return NOTHING; + } + if (members.includes(beforeId) || topLevelAfter(holder, members.at(-1) ?? '') === beforeId) return NOTHING; + return { commands: members.map((id) => new ReorderObjectCommand(holder.id, id, beforeId)), unfold: [] }; +} + +/** The top-level object listed right after `id` and its subtree, '' at the end. */ +function topLevelAfter(holder: ObjectTree, id: string): string { + const top = treeOrder(holder) + .filter((e) => e.depth === 0) + .map((e) => e.object.id); + return top[top.indexOf(id) + 1] ?? ''; +} + +/** Whether the object is stored right in front of `beforeId` among its siblings already. */ +function standsBefore(holder: ObjectTree, id: string, parentId: string, beforeId: string): boolean { + const object = findObject(holder, id); + if (object?.parentId !== parentId) return false; + const siblings = childIdsOf(holder, parentId); + return (siblings[siblings.indexOf(id) + 1] ?? '') === beforeId; +} diff --git a/src/editor/panels/icons/group.svg b/src/editor/panels/icons/group.svg new file mode 100644 index 00000000..3babe8a9 --- /dev/null +++ b/src/editor/panels/icons/group.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index f7fed84e..56ca4cc6 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -71,6 +71,8 @@ interface DragState { /** Set once the pointer travelled past `DRAG_THRESHOLD`. */ active: boolean; spot: DropSpot | null; + /** Pressed inside a multi-selection: a release that never travelled narrows it to this row. */ + narrows: boolean; } /** @@ -154,6 +156,13 @@ export class ListView extends Widget { if (!silent) this.onSelect?.(id); } + /** Like `select`, but reports even the row that is already selected – that is what narrows a multi-selection. */ + private pick(id: string): void { + this.selectedId = id; + this.redraw(); + this.onSelect?.(id); + } + private setContext(id: string | null): void { if (id === this.contextId) return; this.contextId = id; @@ -297,11 +306,23 @@ export class ListView extends Widget { // A row that leaves the list is selected on release instead (see `onDragEnd`): picking // something up is not choosing it, and selecting an asset can move the very view it is // being dragged into. - if (!this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId)) + // A press inside a multi-selection keeps it, so the selection is still there while dragging; + // only a plain click (see `onDragEnd`) narrows it down to this row. + const narrows = this.highlighted.size > 1 && this.highlighted.has(item.id); + const dragged = Boolean(this.onDrop || this.onDragOut); + if (narrows && !dragged) this.pick(item.id); + else if (!narrows && !this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId)) this.select(item.id); if (pressCount(e, item.id) === 2) this.onActivate?.(item.id); - else if (this.onDrop || this.onDragOut) { - this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null }; + else if (dragged) { + this.drag = { + id: item.id, + startX: e.global.x, + startY: e.global.y, + active: false, + spot: null, + narrows, + }; } } } @@ -349,7 +370,8 @@ export class ListView extends Widget { this.cursor = 'default'; if (!drag?.active) { // The press never travelled, so it was a click after all – now it may select. - if (drag && this.onDragOut) this.select(drag.id); + if (drag?.narrows) this.pick(drag.id); + else if (drag && this.onDragOut) this.select(drag.id); return; } this.invalidate(); diff --git a/tests/core/hierarchy.test.ts b/tests/core/hierarchy.test.ts index 9d287dea..3697d0e0 100644 --- a/tests/core/hierarchy.test.ts +++ b/tests/core/hierarchy.test.ts @@ -13,7 +13,7 @@ import { findObject, type ObjectTree, treeOrder, - visibleTreeOrder, + hierarchyRows, } from '../../src/core/model/hierarchy'; import { findScene } from '../../src/core/model/Project'; import { localPositionFor } from '../../src/core/properties/transform'; @@ -94,7 +94,9 @@ describe('scene hierarchy', () => { stack.execute(new SetParentCommand(sceneId, c, a)); stack.execute(new SetParentCommand(sceneId, b, c)); const rows = (expanded: string[]) => - visibleTreeOrder(sceneOf(), new Set(expanded)).map((e) => [e.object.id, e.depth, e.hasChildren]); + hierarchyRows(sceneOf(), new Set(expanded)).flatMap((e) => + e.kind === 'object' ? [[e.object.id, e.depth, e.hasChildren]] : [], + ); // Nothing expanded (the default when a scene is opened) – only the top level is listed. expect(rows([])).toEqual([[a, 0, true]]); diff --git a/tests/core/object-groups.test.ts b/tests/core/object-groups.test.ts new file mode 100644 index 00000000..63346407 --- /dev/null +++ b/tests/core/object-groups.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { + GroupObjectsCommand, + RenameGroupCommand, + SetObjectGroupCommand, + UngroupCommand, +} from '../../src/core/commands/groupCommands'; +import { PasteObjectsCommand } from '../../src/core/commands/pasteCommands'; +import { PlaceAssetCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands'; +import type { ContainerAsset } from '../../src/core/model/Asset'; +import { copyObjects } from '../../src/core/model/clipboard'; +import { + canGroup, + findObject, + groupMemberIds, + groupRowId, + hierarchyRows, + treeOrder, +} from '../../src/core/model/hierarchy'; +import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; +import { findAsset, findScene } from '../../src/core/model/Project'; +import { serializeProject } from '../../src/core/store/serialize'; +import { makeSpriteScene, place, roundTrip } from '../helpers'; + +/** Five top-level boxes a–e in that stored order; `c` has a child `k`. */ +function fiveBoxes() { + const f = makeSpriteScene(); + const [a, b, c, d, e] = [0, 1, 2, 3, 4].map((i) => place(f, f.spriteId, { x: i * 10, y: 0 })); + const k = place(f, f.spriteId, { x: 20, y: 10 }, c); + return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k }; +} + +const ids = (entries: { object: { id: string } }[]) => entries.map((entry) => entry.object.id); + +describe('hierarchy groups (#241)', () => { + it('lists a group as one block where its first member stands, and draws equal z in that order', () => { + const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes(); + stack.execute(new GroupObjectsCommand(sceneId, [b, d], 'Crates')); + // d moves up behind b; the stored list is not touched. + expect(ids(treeOrder(sceneOf()))).toEqual([a, b, d, c, k, e]); + expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c, d, e, k]); + }); + + it('shows a row over the members, indents them and folds them away', () => { + const { stack, sceneId, sceneOf, a, b, c, d, e, k } = fiveBoxes(); + const group = new GroupObjectsCommand(sceneId, [c, e], 'Crates'); + stack.execute(group); + const row = groupRowId(group.groupId); + const rows = (expanded: string[]) => + hierarchyRows(sceneOf(), new Set(expanded)).map((r) => + r.kind === 'group' ? [r.group.name, r.memberIds.length, r.depth] : [r.object.id, r.depth], + ); + // Folded, the way a freshly opened scene starts. + expect(rows([])).toEqual([ + [a, 0], + [b, 0], + ['Crates', 2, 0], + [d, 0], + ]); + // Open: members one step in, the child of a member one more. + expect(rows([row, c])).toEqual([ + [a, 0], + [b, 0], + ['Crates', 2, 0], + [c, 1], + [k, 2], + [e, 1], + [d, 0], + ]); + expect(rows([row])).toEqual([ + [a, 0], + [b, 0], + ['Crates', 2, 0], + [c, 1], + [e, 1], + [d, 0], + ]); + }); + + it('only groups top-level objects', () => { + const { stack, sceneId, sceneOf, a, k } = fiveBoxes(); + expect(canGroup(sceneOf(), [a])).toBe(true); + expect(canGroup(sceneOf(), [a, k])).toBe(false); + expect(() => stack.execute(new GroupObjectsCommand(sceneId, [a, k], 'G'))).toThrow(/top-level/); + expect(sceneOf().groups).toBeUndefined(); + expect(findObject(sceneOf(), a)!.groupId).toBeUndefined(); + }); + + it('takes an object out of its group when it goes under a parent, and back on undo', () => { + const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); + const group = new GroupObjectsCommand(sceneId, [b, c], 'G'); + stack.execute(group); + stack.execute(new SetParentCommand(sceneId, b, a)); + expect(findObject(sceneOf(), b)!.groupId).toBeUndefined(); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c]); + stack.undo(); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); + }); + + it('renames, ungroups and moves members in and out, each one undo step', () => { + const { stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); + const group = new GroupObjectsCommand(sceneId, [a, b], 'Group'); + stack.execute(group); + stack.execute(new RenameGroupCommand(sceneId, group.groupId, 'Crates')); + stack.execute(new SetObjectGroupCommand(sceneId, [c], group.groupId)); + stack.execute(new SetObjectGroupCommand(sceneId, [a], '')); + expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Crates' }]); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); + + stack.execute(new UngroupCommand(sceneId, group.groupId)); + expect(sceneOf().groups).toBeUndefined(); + expect(sceneOf().objects.some((o) => o.groupId)).toBe(false); + + stack.undo(); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([b, c]); + stack.undo(); + stack.undo(); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, b]); + stack.undo(); + expect(sceneOf().groups).toEqual([{ id: group.groupId, name: 'Group' }]); + stack.undo(); + expect(sceneOf().groups).toBeUndefined(); + expect(sceneOf().objects.some((o) => o.groupId !== undefined)).toBe(false); + }); + + it('keeps groups across saving and drops what no longer holds together', () => { + const f = fiveBoxes(); + const { project, stack, sceneId, a, b, c, k } = f; + const kept = new GroupObjectsCommand(sceneId, [a, c], 'Crates'); + const emptied = new GroupObjectsCommand(sceneId, [b], 'Gone'); + stack.execute(kept); + stack.execute(emptied); + stack.execute(new SetObjectGroupCommand(sceneId, [b], '')); + // A group id on a child and one naming no group, as a hand-edited file could have them. + const raw = serializeProject(project) as { + assets: { id: string; objects?: { id: string; groupId?: string }[] }[]; + }; + const scene = raw.assets.find((asset) => asset.id === sceneId)!; + scene.objects!.find((o) => o.id === k)!.groupId = kept.groupId; + scene.objects!.find((o) => o.id === b)!.groupId = 'nowhere'; + + const loaded = findScene(roundTrip(raw as never), sceneId)!; + expect(loaded.groups).toEqual([{ id: kept.groupId, name: 'Crates' }]); + expect(groupMemberIds(loaded, kept.groupId)).toEqual([a, c]); + expect(findObject(loaded, k)!.groupId).toBeUndefined(); + expect(findObject(loaded, b)!.groupId).toBeUndefined(); + }); + + it('loads a project from before groups unchanged', () => { + const { project, sceneId } = fiveBoxes(); + const loaded = roundTrip(project); + expect(JSON.stringify(serializeProject(loaded))).not.toMatch(/group/i); + expect(findScene(loaded, sceneId)!.groups).toBeUndefined(); + expect(treeOrder(findScene(loaded, sceneId)!).map((e) => e.object.id)).toEqual( + treeOrder(findScene(project, sceneId)!).map((e) => e.object.id), + ); + }); + + it('pastes a copy into the group only at the top level of a holder that has the group', () => { + const { project, stack, sceneId, sceneOf, a, b } = fiveBoxes(); + const group = new GroupObjectsCommand(sceneId, [a], 'Crates'); + stack.execute(group); + const clip = copyObjects(project, sceneOf(), [a])!; + + const beside = new PasteObjectsCommand(sceneId, clip, {}, { x: 5, y: 0 }); + stack.execute(beside); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([a, beside.rootIds[0]]); + + const under = new PasteObjectsCommand(sceneId, clip, { [a]: b }, { x: 5, y: 0 }); + stack.execute(under); + expect(findObject(sceneOf(), under.rootIds[0]!)!.groupId).toBeUndefined(); + + const other = new CreateAssetCommand('scene', 'Other'); + stack.execute(other); + const elsewhere = new PasteObjectsCommand(other.asset.id, clip, {}, { x: 0, y: 0 }); + stack.execute(elsewhere); + expect(findObject(findScene(project, other.asset.id)!, elsewhere.rootIds[0]!)!.groupId).toBeUndefined(); + }); + + it('puts a container made of one group in its place and leaves the template without groups', () => { + const { project, stack, sceneId, sceneOf, a, b, c } = fiveBoxes(); + const group = new GroupObjectsCommand(sceneId, [a, b, c], 'Crates'); + stack.execute(group); + const create = new CreateContainerCommand(sceneId, [a, b]); + stack.execute(create); + expect(groupMemberIds(sceneOf(), group.groupId)).toEqual([c, create.objectId]); + const template = findAsset(project, create.assetId) as ContainerAsset; + expect(template.objects.some((o) => o.groupId !== undefined)).toBe(false); + }); + + it('orders the content of a placed container the way its template lists it', () => { + const f = makeSpriteScene(); + const container = new CreateAssetCommand('container', 'Crates'); + f.stack.execute(container); + const holderId = container.asset.id; + const [x, y, z] = [0, 1, 2].map((i) => { + const put = new PlaceAssetCommand(holderId, f.spriteId, { x: i * 10, y: 0 }); + f.stack.execute(put); + return put.objectId; + }); + f.stack.execute(new GroupObjectsCommand(holderId, [x!, z!], 'Pair')); + const template = findAsset(f.project, holderId) as ContainerAsset; + expect(ids(treeOrder(template))).toEqual([x, z, y]); + + const instance = place(f, holderId, { x: 0, y: 0 }); + const content = treeOrder(resolvedHolder(f.project, f.sceneOf())) + .filter((entry) => entry.object.parentId === instance) + .map((entry) => entry.object.id); + expect(content).toEqual([x, z, y].map((id) => instanceChildId(instance, id!))); + }); +}); diff --git a/tests/editor/hierarchy-drop.test.ts b/tests/editor/hierarchy-drop.test.ts new file mode 100644 index 00000000..8f030679 --- /dev/null +++ b/tests/editor/hierarchy-drop.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; +import { oneStep } from '../../src/core/commands/CompoundCommand'; +import { GroupObjectsCommand } from '../../src/core/commands/groupCommands'; +import { + findObject, + groupMemberIds, + groupRowId, + hierarchyRows, + type HierarchyRow, +} from '../../src/core/model/hierarchy'; +import { hierarchyDrop } from '../../src/editor/panels/hierarchyDrop'; +import type { DropSpot } from '../../src/ui/widgets/ListView'; +import { makeSpriteScene, place } from '../helpers'; + +/** Top-level a, b, c, d, e; b and d form the group "Crates", so the list reads a [b d] c e. */ +function grouped() { + const f = makeSpriteScene(); + const [a, b, c, d, e] = [0, 1, 2, 3, 4].map((i) => place(f, f.spriteId, { x: i * 10, y: 0 })); + const k = place(f, f.spriteId, { x: 0, y: 10 }, a); + const group = new GroupObjectsCommand(f.sceneId, [b!, d!], 'Crates'); + f.stack.execute(group); + const row = groupRowId(group.groupId); + /** Drops like the panel does, with the given rows unfolded; returns how many commands it made. */ + const drop = (dragged: string, spot: DropSpot, open: string[] = [row]): number => { + const { commands } = hierarchyDrop(f.sceneOf(), dragged, spot, (id) => open.includes(id)); + if (commands.length > 0) f.stack.execute(oneStep('Move object', commands)); + return commands.length; + }; + const label = (r: HierarchyRow) => (r.kind === 'group' ? `[${r.group.name}]` : name(r.object.id)); + const names: Record = { [a!]: 'a', [b!]: 'b', [c!]: 'c', [d!]: 'd', [e!]: 'e', [k]: 'k' }; + const name = (id: string) => names[id] ?? '?'; + /** The open list as text: a group's members follow it one step in. */ + const listed = () => + hierarchyRows(f.sceneOf(), new Set([row, a!])) + .map((r) => `${'.'.repeat(r.depth)}${label(r)}`) + .join(' '); + const members = () => groupMemberIds(f.sceneOf(), group.groupId).map(name); + return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k, row, drop, listed, members }; +} + +describe('dropping rows in the hierarchy with groups (#241)', () => { + it('starts from a group listed as one block', () => { + const f = grouped(); + expect(f.listed()).toBe('a .k [Crates] .b .d c e'); + }); + + it('puts an object dropped onto the group row behind its last member', () => { + const f = grouped(); + f.drop(f.e, { at: 'onto', targetId: f.row }); + expect(f.members()).toEqual(['b', 'd', 'e']); + expect(f.listed()).toBe('a .k [Crates] .b .d .e c'); + f.stack.undo(); + expect(f.listed()).toBe('a .k [Crates] .b .d c e'); + }); + + it('joins the group between two members, and at the top just below an open group row', () => { + const f = grouped(); + f.drop(f.e, { at: 'after', targetId: f.b }); + expect(f.listed()).toBe('a .k [Crates] .b .e .d c'); + f.drop(f.c, { at: 'after', targetId: f.row }); + expect(f.listed()).toBe('a .k [Crates] .c .b .e .d'); + }); + + it('goes behind a folded group, and in front of any group row, without joining', () => { + const f = grouped(); + f.drop(f.a, { at: 'after', targetId: f.row }, []); + expect(f.listed()).toBe('[Crates] .b .d a .k c e'); + f.drop(f.e, { at: 'before', targetId: f.row }); + expect(f.listed()).toBe('e [Crates] .b .d a .k c'); + expect(f.members()).toEqual(['b', 'd']); + }); + + it('takes a member out when it is dropped beside a row outside the group', () => { + const f = grouped(); + f.drop(f.d, { at: 'after', targetId: f.e }); + expect(f.members()).toEqual(['b']); + expect(f.listed()).toBe('a .k [Crates] .b c e d'); + }); + + it('brings a child up to the top level when it is dropped into a group', () => { + const f = grouped(); + f.drop(f.k, { at: 'before', targetId: f.d }); + expect(findObject(f.sceneOf(), f.k)!.parentId).toBe(''); + expect(f.listed()).toBe('a [Crates] .b .k .d c e'); + }); + + it('takes a member out of the group when it is dropped onto another object', () => { + const f = grouped(); + f.drop(f.b, { at: 'onto', targetId: f.c }); + expect(findObject(f.sceneOf(), f.b)!.groupId).toBeUndefined(); + expect(f.members()).toEqual(['d']); + }); + + it('moves a group row with all its members as one block', () => { + const f = grouped(); + f.drop(f.row, { at: 'after', targetId: f.e }); + expect(f.listed()).toBe('a .k c e [Crates] .b .d'); + f.drop(f.row, { at: 'before', targetId: f.a }); + expect(f.listed()).toBe('[Crates] .b .d a .k c e'); + // One undo takes the whole block back. + f.stack.undo(); + expect(f.listed()).toBe('a .k c e [Crates] .b .d'); + }); + + it('makes nothing of a drop that leaves everything where it is', () => { + const f = grouped(); + expect(f.drop(f.d, { at: 'after', targetId: f.b })).toBe(0); + expect(f.drop(f.b, { at: 'onto', targetId: f.row })).toBe(0); + expect(f.drop(f.row, { at: 'before', targetId: f.c })).toBe(0); + expect(f.drop(f.row, { at: 'onto', targetId: f.c })).toBe(0); + expect(f.drop(f.row, { at: 'before', targetId: f.k })).toBe(0); + expect(f.listed()).toBe('a .k [Crates] .b .d c e'); + }); +}); diff --git a/tests/ui/svg-icons.test.ts b/tests/ui/svg-icons.test.ts index 2f767cc2..0d04007b 100644 --- a/tests/ui/svg-icons.test.ts +++ b/tests/ui/svg-icons.test.ts @@ -4,6 +4,7 @@ import { DECLARED_PORT_KINDS, FIELD_KINDS, kindMark, VARIABLE_KINDS } from '../. import { ASSET_ICON_SVG, GAME_ICON_SVG, + GROUP_ICON_SVG, LIST_ICON_SVG, PLUGINS_ICON_SVG, USERS_ICON_SVG, @@ -45,6 +46,7 @@ describe('icon files', () => { ...Object.entries(ASSET_ICON_SVG).map(([name, svg]) => [`asset ${name}`, svg]), ['list tab', LIST_ICON_SVG], ['game tab', GAME_ICON_SVG], + ['hierarchy group', GROUP_ICON_SVG], ['plugins tab', PLUGINS_ICON_SVG], ['user management tab', USERS_ICON_SVG], ...Object.entries(TOOLBAR_ICON_SVG).map(([name, svg]) => [`toolbar ${name}`, svg]), diff --git a/tools/e2e/52-hierarchy-groups.mjs b/tools/e2e/52-hierarchy-groups.mjs new file mode 100644 index 00000000..5c8e9f68 --- /dev/null +++ b/tools/e2e/52-hierarchy-groups.mjs @@ -0,0 +1,149 @@ +import { same } from '../lib/harness.mjs'; + +/** + * Groups in the hierarchy (#241), made, opened, filled, renamed and dissolved with the real menus, + * clicks, keys and a real drag. The fixture's top level is o1, o2, o3, o5; the order rules and + * drops are unit-tested, this checks that the panel and the dialog do what those rules say. + * Everything is taken back by undo, so the fixture leaves as it came. + */ +export async function hierarchyGroups(t) { + const { check, json, evaluate, click, clickText, drag, press, typeText, select, showTab, undo, shot } = t; + + const rowIds = () => json('__gm.hierarchyOrder()'); + const scene = () => json('__gm.state.activeScene'); + const selected = async () => (await json('__gm.state.selection'))?.objectIds ?? []; + const rowAt = (id) => json(`__gm.hierarchyRow(${JSON.stringify(id)})`); + const menuRow = async (label) => { + const rows = (await json('__gm.menuRows()')) ?? []; + return { rows, enabled: ((await json('__gm.menuEnabled()')) ?? [])[rows.indexOf(label)] }; + }; + + await press('Escape'); + await showTab('scene'); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + + // 52a. A child cannot go into a group: the entry is there, greyed out. + await select({ kind: 'object', holderId: 'scene1', objectId: 'oc2', objectIds: ['oc2'] }); + const child = await rowAt('oc2'); + await click(child.x, child.y, 'right'); + const childEntry = await menuRow('Group…'); + check('Group… is greyed out for a child', childEntry.enabled === false, JSON.stringify(childEntry)); + await press('Escape'); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + + // 52b. Two top-level objects, the row menu's "Group 2 objects…", a name typed into the dialog. + await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o3'] }); + const first = await rowAt('o1'); + await click(first.x, first.y, 'right'); + const entry = await menuRow('Group 2 objects…'); + await clickText('Group 2 objects…'); + const dialogOpened = await json('__gm.groupDialogOpen()'); + await typeText('Crates'); + await press('Enter'); + const made = await scene(); + const group = made.groups?.[0]; + const row = `group:${group?.id}`; + check( + 'Group 2 objects… asks for a name and makes the group of both', + entry.enabled === true && + dialogOpened === true && + (await json('__gm.groupDialogOpen()')) === false && + made.groups?.length === 1 && + group.name === 'Crates' && + made.objects + .filter((o) => o.groupId === group.id) + .map((o) => o.id) + .join() === 'o1,o3', + JSON.stringify({ entry, dialogOpened, groups: made.groups }), + ); + // o3 is listed with o1 now; with both members selected the group stays folded. + check( + 'the group row stands where o1 stood, folded', + same(await rowIds(), [row, 'o2', 'o5']), + JSON.stringify(await rowIds()), + ); + + // 52c. A click on the group row selects the members, the chevron unfolds them. + await evaluate('__gm.state.select(null)'); + const groupRow = await rowAt(row); + await click(groupRow.x, groupRow.y); + check( + 'clicking the group row selects all its members and leaves it folded', + same(await selected(), ['o1', 'o3']) && same(await rowIds(), [row, 'o2', 'o5']), + JSON.stringify({ selected: await selected(), rows: await rowIds() }), + ); + const chevron = await json(`__gm.hierarchyChevron(${JSON.stringify(row)})`); + await click(chevron.x, chevron.y); + check( + 'its chevron lists the members below it', + same(await rowIds(), [row, 'o1', 'o3', 'o2', 'o5']), + JSON.stringify(await rowIds()), + ); + await shot('52-group-open'); + + // 52d. Dragging o5 onto the group row puts it in behind the last member; undo takes it out. + const from = await rowAt('o5'); + const onto = await rowAt(row); + await drag(from.x, from.y, onto.x, onto.y); + check( + 'dragging an object onto the group row adds it to the group', + same(await rowIds(), [row, 'o1', 'o3', 'o5', 'o2']) && + (await scene()).objects.find((o) => o.id === 'o5')?.groupId === group?.id, + JSON.stringify(await rowIds()), + ); + await undo(); + + // 52e. Rename… from the group row's menu, typed over the selected old name. + await click(groupRow.x, groupRow.y, 'right'); + const groupMenu = (await json('__gm.menuRows()')) ?? []; + await clickText('Rename…'); + await typeText('Boxes'); + await press('Enter'); + check( + "the group row's menu renames it", + ['Select members', 'Rename…', 'Ungroup'].every((label) => groupMenu.includes(label)) && + (await scene()).groups?.[0]?.name === 'Boxes', + JSON.stringify({ groupMenu, groups: (await scene()).groups }), + ); + + // 52f. Ungroup keeps the objects and gives back the list of before. + await click(groupRow.x, groupRow.y, 'right'); + await clickText('Ungroup'); + const ungrouped = await scene(); + check( + 'Ungroup dissolves the group and keeps its members', + ungrouped.groups === undefined && + !ungrouped.objects.some((o) => o.groupId) && + same(await rowIds(), ['o1', 'o2', 'o3', 'o5']), + JSON.stringify({ groups: ungrouped.groups, rows: await rowIds() }), + ); + + // Ungroup, rename and group: three steps back to the fixture. + await undo(); + await undo(); + await undo(); + const back = await scene(); + check( + 'undo takes each step back on its own, down to no group at all', + back.groups === undefined && !back.objects.some((o) => 'groupId' in o), + JSON.stringify(back.groups), + ); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); + + // 52g. A plain click on a row that is already part of a multi-selection keeps only that row – + // the other member as well as the primary one. + await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] }); + const other = await rowAt('o2'); + await click(other.x, other.y); + const narrowedToOther = await selected(); + await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2', 'o3'] }); + const primary = await rowAt('o1'); + await click(primary.x, primary.y); + const narrowedToPrimary = await selected(); + check( + 'clicking a selected row narrows the selection to that row', + same(narrowedToOther, ['o2']) && same(narrowedToPrimary, ['o1']), + JSON.stringify({ narrowedToOther, narrowedToPrimary }), + ); + await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index ffb2ec2d..18c820fa 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -54,6 +54,7 @@ import { pixelArt } from './48-pixelart.mjs'; import { users } from './49-users.mjs'; import { snapshots } from './50-snapshots.mjs'; import { clipboard } from './51-clipboard.mjs'; +import { hierarchyGroups } from './52-hierarchy-groups.mjs'; export const STEPS = [ hierarchy, @@ -107,4 +108,5 @@ export const STEPS = [ users, snapshots, clipboard, + hierarchyGroups, ];