Group scene objects in the hierarchy, for the editor only (#241)
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:
1 parent
2b89292eb4
commit
53fa63441b
26 files changed
+1174
-83
No files matched your search
@@ -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<string, string> = { [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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user