Group scene objects in the hierarchy, for the editor only (#241)
game-maker / gate (push) Successful in 3m58s
game-maker / suite (push) Failing after 9m58s

A scene with a hundred crates fills the hierarchy with a hundred rows.
Groups bundle top-level objects under one folder row that folds away.
They exist for the editor only. Parenting, positions and the game do
not see them.

- Model: a scene or container template holds a list of groups, an
  object may carry a groupId. Old projects load unchanged. Loading
  drops empty groups and members that are no longer top level.
- Only top-level objects can join a group. Parenting a member takes it
  out of the group, and Undo puts it back. There are no groups in
  groups.
- Order: treeOrder lists the members together, where the first one
  stands. The same order decides drawing at equal z, in the editor and
  in the game, so the hierarchy and the picture cannot disagree. The
  stored object list stays untouched when grouping.
- Hierarchy: a group row shows a folder icon, the name and the member
  count. Clicking it selects all members and keeps it folded. Selecting
  a single member elsewhere unfolds it. Folding lasts for the session.
- Create with "Group..." in the object menu. A name dialog asks first,
  so creating is one Undo step and Cancel creates nothing.
- The group menu has Select members, Rename... and Ungroup. The object
  menu adds "Remove from group".
- Drag and drop: onto the group row appends, into a gap between members
  inserts there, next to a row outside leaves the group. Dragging the
  group row moves all members as a block. The drop rules live in
  hierarchyDrop.ts as a pure function.
- Copy and paste keeps a top-level copy in the group of its original.
  A container built from group members takes their place in the group.
- Clicking an object that is already selected together with others now
  keeps only that object selected. ListView narrows the selection on
  release, so a drag starting on such a row keeps the selection.

Not checked in the browser: groups in the container tab, "Remove from
group", Ctrl-click on a group row and a drag out of a multi-selection.
The code path covers them.

Tests:
- check.sh: 177 files, 2552 tests, CHECK EXIT 0
- e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52)
- 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts
- 9 counter-probes, all fail as expected (7 unit, 2 in the browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 01:17:46 +02:00
1 parent 2b89292eb4
commit 53fa63441b
26 files changed
+1174 -83

No files matched your search

+6
View File
@@ -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);
+137
View File
@@ -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<string, string | undefined>();
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;
}
}
+10 -2
View File
@@ -10,8 +10,16 @@ function firstFree(taken: ReadonlySet<string>, 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. */
+4
View File
@@ -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,
+6 -2
View File
@@ -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;
}
}
+5 -1
View File
@@ -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). */
+11
View File
@@ -29,6 +29,17 @@ export interface SceneObject {
* stored – it follows the template.
*/
instanceEdits: Record<string, InstanceEdit>;
/**
* 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(
+81 -16
View File
@@ -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<string, SceneObject[]>();
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<string>): VisibleEntry[] {
export function hierarchyRows(tree: ObjectTree, expanded: ReadonlySet<string>): 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;
}
+3 -2
View File
@@ -3,7 +3,7 @@
* in sparse `instanceEdits` keyed by template-id path; derived ids `<instance>~<path>` 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
+34 -2
View File
@@ -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<string, ObjectGroup>();
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<string>();
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
+3
View File
@@ -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,
};
}
+92
View File
@@ -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]));
}
+2
View File
@@ -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
+38
View File
@@ -0,0 +1,38 @@
import { TextField, ui } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
const FIELD_WIDTH = 220;
const slot = dialogSlot<GroupNameDialog>();
/** 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;
}
}
+97 -51
View File
@@ -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<string>();
/**
* 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<string>();
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<string>();
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);
}
}
+4
View File
@@ -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<AssetType, string> = {
/** 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;
+127
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<path d="M-6.75 -4.75 H-1.5 L0 -3.25 H6.75 V5.25 H-6.75 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 234 B

+26 -4
View File
@@ -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();