Drag a whole selection and join existing groups in the hierarchy (#241)
game-maker / gate (push) Successful in 3m26s
game-maker / suite (push) Failing after 9m12s

Three follow-ups to the hierarchy groups. Dragging a row that belonged
to a multi-selection moved only that one row. The group dialog could
only create a new group. The group row drew its folder in front of the
label, unlike every other row in the hierarchy.

- Dragging a selected row moves every selected object. This holds for
  reordering, dropping into a group or between its members, dragging
  out of a group and dropping onto an object as new parent.
- The moved objects land side by side in hierarchy order, whichever
  row was grabbed. The whole move is one undo step.
- A selected child of a selected object travels with its parent, not
  on its own. Dropping the selection onto its own rows does nothing.
- Dragging a row that is not selected still moves only that row.
- hierarchyDrop.moveObjects replaces the single-object rule. One object
  is the case with one element, so the old drop tests hold unchanged.
- The group dialog has a "Group" dropdown: New group or an existing
  group. Picking a group greys out the name and turns the button into
  "Add". A group that holds all chosen objects already is not offered.
- Rename keeps using the dialog without the dropdown.
- ListRow draws an end icon at the very right and the hint left of it.
  Only the hierarchy uses iconAtEnd, so other lists do not change.
- New test hook __gm.hierarchyHint() gives the centre of a row's hint.

Not clicked in the browser: dragging a selection in the container tab
and onto an object as parent. Only the unit tests cover these.

Tests:
- ./check.sh: 178 files, 2564 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 27 checks
- 7 new unit tests in hierarchy-drop.test.ts, 4 new e2e checks in
  52i and 52j with real clicks and a real drag
- 4 counter-probes bite: drop rule ignores the selection (5 unit tests
  fail), icon column not kept free, dialog ignores the picked group,
  panel ignores the selection (only o2 lands in the group)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 11:19:12 +02:00
1 parent 893b81f798
commit 2eeabf7c23
10 files changed
+327 -64

No files matched your search

+1 -1
View File
@@ -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, 1443 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1447 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
+2
View File
@@ -298,6 +298,8 @@ export function uiHooks({ app, state, editor }: HookContext) {
hierarchyRowColor: (id: string) => hierarchy.rowColor(id),
/** Centre of the type icon at the right edge of a hierarchy row. */
hierarchyIcon: (id: string) => hierarchy.iconPosition(id),
/** Centre of the hint of a hierarchy row – a group's member count. */
hierarchyHint: (id: string) => hierarchy.hintPosition(id),
hierarchyExpandAll: () => hierarchy.expandAll(),
hierarchyCollapseAll: () => hierarchy.collapseAll(),
/** Whether the dialog naming a hierarchy group is open (#241). */
+19 -3
View File
@@ -45,12 +45,28 @@ export function groupMenuItems(
];
}
/**
* Asks for the name of a new group – or, from a dropdown, for an existing group to put the objects
* into. A group all of them are in already is not offered.
*/
export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object';
const groups = (source.groups ?? []).filter((g) => {
const members = groupMemberIds(source, g.id);
return members.length > 0 && !ids.every((id) => members.includes(id));
});
GroupNameDialog.open(
new GroupNameDialog(title, nextGroupName(source), 'Group', (name) => {
state.execute(new GroupObjectsCommand(source.id, ids, name));
}),
new GroupNameDialog(
title,
nextGroupName(source),
'Group',
(name) => state.execute(new GroupObjectsCommand(source.id, ids, name)),
{
groups,
confirm: 'Add',
onJoin: (groupId) => state.execute(new SetObjectGroupCommand(source.id, ids, groupId)),
},
),
);
}
+54 -5
View File
@@ -1,23 +1,49 @@
import { TextField, ui } from '../../ui';
import type { ObjectGroup } from '../../core/model/SceneObject';
import { type Button, Dropdown, Menu, TextField, ui } from '../../ui';
import { dialogSlot, FormDialog } from '../FormDialog';
const FIELD_WIDTH = 220;
/** The dropdown entry that makes a group instead of joining one. */
export const NEW_GROUP = 'New group';
const slot = dialogSlot<GroupNameDialog>();
/** The open group name dialog, for the check scripts. */
export const currentGroupDialog = slot.current;
/** Offered by the dialog that groups objects: an existing group to put them into instead. */
export interface GroupJoin {
groups: readonly ObjectGroup[];
onJoin: (groupId: string) => void;
/** Label of the confirming button while a group is chosen. */
confirm: string;
}
/**
* 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.
* picked up with the caret already in it, so typing replaces it straight away. Given `join`, a
* dropdown above the name offers the existing groups too; picking one puts the objects into it.
*/
export class GroupNameDialog extends FormDialog {
private readonly field: TextField;
private readonly choice: Dropdown | undefined;
private readonly confirmButton: Button;
/** The existing group chosen in the dropdown, '' for a new one. */
private joinId = '';
constructor(title: string, name: string, confirm: string, onName: (name: string) => void) {
constructor(
title: string,
name: string,
confirm: string,
onName: (name: string) => void,
private readonly join?: GroupJoin,
) {
super(320, title);
const accept = () => onName(this.field.value.trim() || name);
const accept = () => {
if (this.joinId) join?.onJoin(this.joinId);
else onName(this.field.value.trim() || name);
};
this.field = new TextField(name, {
width: FIELD_WIDTH,
onSubmit: () => {
@@ -25,8 +51,31 @@ export class GroupNameDialog extends FormDialog {
accept();
},
});
if (join && join.groups.length > 0) {
const choice = new Dropdown(NEW_GROUP, { width: FIELD_WIDTH });
choice.onClick = () => new Menu(this.choiceItems(confirm)).openBelow(choice);
this.choice = choice;
this.content.add(this.formRow(this.fieldLabel('Group'), choice));
}
this.content.add(this.formRow(this.fieldLabel('Name'), this.field));
this.addButtons(confirm, 'primary', accept);
this.confirmButton = this.addButtons(confirm, 'primary', accept);
}
private choiceItems(confirm: string): { label: string; onSelect: () => void }[] {
const pick = (groupId: string, label: string) => () => {
this.joinId = groupId;
this.choice?.setText(label);
this.field.setEnabled(!groupId);
this.confirmButton.setText(groupId ? (this.join?.confirm ?? confirm) : confirm);
if (!groupId) ui().focus.focus(this.field);
};
return [
{ label: NEW_GROUP, onSelect: pick('', NEW_GROUP) },
...(this.join?.groups ?? []).map((g) => ({
label: g.name || 'Group',
onSelect: pick(g.id, g.name || 'Group'),
})),
];
}
/** Opens the dialog centred with the name selected. */
+16 -3
View File
@@ -79,15 +79,22 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/** A move that leaves the order as it is does nothing, so a drag back to its start costs no undo step. */
/**
* A row dragged out of the multi-selection takes the other selected objects along. 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();
if (!holder) return;
const { commands, unfold } = hierarchyDrop(holder, draggedId, spot, (rowId) => this.expanded.has(rowId));
const s = this.state.selection;
const selected = s?.kind === 'object' && s.holderId === holder.id ? s.objectIds : [];
const isOpen = (rowId: string) => this.expanded.has(rowId);
const { commands, unfold } = hierarchyDrop(holder, draggedId, spot, isOpen, selected);
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));
const many = selected.length > 1 && selected.includes(draggedId);
this.state.execute(oneStep(many ? 'Move objects' : 'Move object', commands));
}
/** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */
@@ -185,6 +192,11 @@ export class HierarchyPanel extends Panel {
return this.list.iconGlobal(id);
}
/** Global centre of a row's hint – a group's member count (test hook). */
hintPosition(id: string): { x: number; y: number } | null {
return this.list.hintGlobal(id);
}
expandAll(): void {
const holder = this.holder();
for (const object of holder?.objects ?? []) this.expanded.add(object.id);
@@ -263,6 +275,7 @@ export class HierarchyPanel extends Panel {
label: row.group.name || 'Group',
hint: String(row.memberIds.length),
icon: GROUP_ICON_SVG,
iconAtEnd: true,
depth: 0,
expanded: this.expanded.has(groupRowId(row.group.id)),
overridden: row.memberIds.some((id) => marks.has(id)),
+80 -43
View File
@@ -2,6 +2,7 @@ import type { Command } from '../../core/commands/Command';
import { SetObjectGroupCommand } from '../../core/commands/groupCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import {
ancestorIdsOf,
childIdsOf,
findGroup,
findObject,
@@ -9,8 +10,10 @@ import {
groupMemberIds,
groupRowId,
type ObjectTree,
subtreeRoots,
treeOrder,
} from '../../core/model/hierarchy';
import type { SceneObject } from '../../core/model/SceneObject';
import type { DropSpot } from '../../ui/widgets/ListView';
export interface HierarchyDrop {
@@ -24,69 +27,96 @@ 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.
* open group's row is inside the group, below a folded one it is behind the group. `selected` is
* the multi-selection: an object dragged out of it takes the other selected objects along.
*/
export function hierarchyDrop(
holder: ObjectTree & { id: string },
draggedId: string,
spot: DropSpot,
isOpen: (rowId: string) => boolean,
selected: readonly string[] = [],
): HierarchyDrop {
const groupId = groupIdOfRow(draggedId);
return groupId ? moveGroup(holder, groupId, spot) : moveObject(holder, draggedId, spot, isOpen);
if (groupId) return moveGroup(holder, groupId, spot);
return moveObjects(holder, draggedObjectIds(holder, draggedId, selected), 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.
* The objects a drag moves, in tree order: the dragged one alone, or the whole selection when it is
* part of it. A selected object below another selected one travels with its parent, not by itself.
*/
function moveObject(
function draggedObjectIds(holder: ObjectTree, draggedId: string, selected: readonly string[]): string[] {
if (!selected.includes(draggedId)) return [draggedId];
const wanted = new Set(selected);
const ordered = treeOrder(holder)
.map((e) => e.object.id)
.filter((id) => wanted.has(id));
return subtreeRoots(holder, ordered);
}
/** The group an object is listed in, '' when none. */
function listedGroup(holder: ObjectTree, object: SceneObject): string {
return !object.parentId && findGroup(holder, object.groupId ?? '') ? object.groupId! : '';
}
/**
* Where dropped objects go: their parent, their group and the object they are stored in front of,
* in the order they were listed. A group lists its members in stored order and stands where its
* first member does, so objects are put in front of the one they should be listed before – or,
* joining a group behind its last member, at the very end. That object is never one of the moved.
*/
function moveObjects(
holder: ObjectTree & { id: string },
draggedId: string,
ids: readonly 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 objects = ids.flatMap((id) => findObject(holder, id) ?? []);
if (objects.length === 0) return NOTHING;
const moving = new Set(objects.map((o) => o.id));
const targetGroup = spot.at === 'end' ? null : groupIdOfRow(spot.targetId);
const target = spot.at === 'end' || targetGroup ? undefined : findObject(holder, spot.targetId);
// Next to or into one of the moved objects, or anything below them, is no place to go.
if (target && [target.id, ...ancestorIdsOf(holder, target.id)].some((id) => moving.has(id))) return NOTHING;
if (spot.at === 'onto' && target) {
if (object.parentId === target.id) return NOTHING;
return { commands: [new SetParentCommand(holder.id, draggedId, target.id)], unfold: [target.id] };
const commands = objects
.filter((o) => o.parentId !== target.id)
.map((o) => new SetParentCommand(holder.id, o.id, target.id));
return commands.length > 0 ? { commands, unfold: [target.id] } : NOTHING;
}
let parentId = '';
let groupId = '';
// The list the objects end up in, as shown; they go in front of the first one in it from
// `index` on that is not moved itself, or at its end.
const top = topLevelIds(holder);
let order = top;
let index = top.length;
if (targetGroup) {
const members = groupMemberIds(holder, targetGroup);
if (spot.at === 'onto' && inGroup === targetGroup) return NOTHING;
if (spot.at === 'onto' && objects.every((o) => listedGroup(holder, o) === 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) ?? '');
order = members;
index = spot.at === 'onto' ? members.length : 0;
} else if (members.length > 0) {
index = spot.at === 'before' ? top.indexOf(members[0]!) : top.indexOf(members.at(-1)!) + 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);
groupId = listedGroup(holder, target);
order = groupId ? groupMemberIds(holder, groupId) : parentId ? childIdsOf(holder, parentId) : top;
index = order.indexOf(target.id) + (spot.at === 'before' ? 0 : 1);
}
const beforeId = order.slice(index).find((id) => !moving.has(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));
for (const object of objects)
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, object.id, parentId));
const regroup = parentId ? [] : objects.filter((o) => listedGroup(holder, o) !== groupId).map((o) => o.id);
if (regroup.length > 0) commands.push(new SetObjectGroupCommand(holder.id, regroup, groupId));
// Each goes in front of the same object in turn, so they end up side by side in listed order.
if (!standInFront(order, [...moving], beforeId))
for (const id of moving) commands.push(new ReorderObjectCommand(holder.id, id, beforeId));
const unfold = [...(parentId ? [parentId] : []), ...(groupId ? [groupRowId(groupId)] : [])];
return { commands, unfold };
}
@@ -110,18 +140,25 @@ function moveGroup(holder: ObjectTree & { id: string }, groupId: string, spot: D
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)
/** Top-level objects as listed, groups kept together. */
function topLevelIds(holder: ObjectTree): string[] {
return treeOrder(holder)
.filter((e) => e.depth === 0)
.map((e) => e.object.id);
}
/** The top-level object listed right after `id` and its subtree, '' at the end. */
function topLevelAfter(holder: ObjectTree, id: string): string {
const top = topLevelIds(holder);
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;
/**
* Whether the objects are listed side by side in this order right in front of `beforeId` in the
* list they go to already – then there is nothing to reorder.
*/
function standInFront(order: readonly string[], ids: readonly string[], beforeId: string): boolean {
const at = order.indexOf(ids[0] ?? '');
if (at < 0) return false;
return ids.every((id, i) => order[at + i] === id) && (order[at + ids.length] ?? '') === beforeId;
}
+12 -5
View File
@@ -77,6 +77,13 @@ export class ListRow extends Widget {
return { x: p.x, y: p.y };
}
/** Global centre of the hint; null for rows without one (test hook). */
hintGlobal(): { x: number; y: number } | null {
if (!this.item.hint) return null;
const p = this.hint.getGlobalPosition();
return { x: p.x + this.hint.width / 2, y: p.y + this.hint.height / 2 };
}
/** Whether a click at this local x lands on the chevron rather than on the row. */
hitsChevron(localX: number): boolean {
if (!this.chevronSlot || this.item.expanded === undefined) return false;
@@ -115,13 +122,13 @@ export class ListRow extends Widget {
mark.position.set(right - ICON_SIZE / 2, this.h / 2);
right -= ICON_SIZE;
}
const marksW = this.w - Theme.padding - right;
this.hint.text = this.item.hint ?? '';
let hintW = (this.hint.text ? this.hint.width + Theme.padding : 0) + marksW;
// An icon at the end stands in the same column on every row; a hint goes in front of it.
if (iconAtEnd) {
this.icon.position.set(this.w - Theme.padding - hintW - ICON_SIZE / 2, this.h / 2);
hintW += ICON_SIZE + Theme.padding;
this.icon.position.set(right - ICON_SIZE / 2, this.h / 2);
right -= ICON_SIZE + Theme.padding;
}
this.hint.text = this.item.hint ?? '';
const hintW = (this.hint.text ? this.hint.width + Theme.padding : 0) + this.w - Theme.padding - right;
this.labelText.text = truncate(this.item.label, this.labelText.style, this.w - x - hintW - Theme.padding);
this.labelText.position.set(x, centreY(this.h, this.labelText.height));
this.hint.position.set(Math.round(right - this.hint.width), centreY(this.h, this.hint.height));
+6 -1
View File
@@ -19,7 +19,7 @@ export interface ListItem {
overridden?: boolean;
/** SVG file drawn in front of the label, 16 units around its origin, `currentColor` muted. */
icon?: string;
/** Draws `icon` at the right edge, where a hint would stand, instead of in front of the label. */
/** Draws `icon` at the right edge instead of in front of the label; a hint then stands left of it. */
iconAtEnd?: boolean;
/** Small pictures (SVG files) at the right edge, right of the hint – states of the row, like a scene's start mark. */
marks?: readonly string[];
@@ -231,6 +231,11 @@ export class ListView extends Widget {
return this.rows.find((r) => r.item.id === id)?.iconGlobal() ?? null;
}
/** Global centre of a row's hint; null when the row has none (test hook). */
hintGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.hintGlobal() ?? null;
}
/** Global centre of a row's chevron; null when the row has none. */
chevronGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
+65 -3
View File
@@ -20,9 +20,12 @@ function grouped() {
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));
/**
* Drops like the panel does, with the given rows unfolded and `selected` as the selection;
* returns how many commands it made.
*/
const drop = (dragged: string, spot: DropSpot, open: string[] = [row], selected: string[] = []): number => {
const { commands } = hierarchyDrop(f.sceneOf(), dragged, spot, (id) => open.includes(id), selected);
if (commands.length > 0) f.stack.execute(oneStep('Move object', commands));
return commands.length;
};
@@ -112,3 +115,62 @@ describe('dropping rows in the hierarchy with groups (#241)', () => {
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
});
describe('dragging several selected rows at once', () => {
it('puts every selected object into the group it is dropped onto, as one undo step', () => {
const f = grouped();
f.drop(f.e, { at: 'onto', targetId: f.row }, [f.row], [f.c, f.e]);
expect(f.members()).toEqual(['b', 'd', 'c', 'e']);
expect(f.listed()).toBe('a .k [Crates] .b .d .c .e');
f.stack.undo();
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
it('moves the selection side by side into a gap, in the order it was listed', () => {
const f = grouped();
// Dragged by its last row, still c comes before e.
f.drop(f.e, { at: 'before', targetId: f.a }, [f.row], [f.e, f.c]);
expect(f.listed()).toBe('c e a .k [Crates] .b .d');
});
it('takes members out of the group and joins others to it in the same drop', () => {
const f = grouped();
f.drop(f.b, { at: 'after', targetId: f.e }, [f.row], [f.b, f.a]);
expect(f.members()).toEqual(['d']);
// The group now stands where d, its first member left, is stored – behind c.
expect(f.listed()).toBe('c [Crates] .d e a .k b');
f.drop(f.a, { at: 'after', targetId: f.d }, [f.row], [f.a, f.c]);
expect(f.members()).toEqual(['d', 'c', 'a']);
});
it('parents every selected object to the row they are dropped onto', () => {
const f = grouped();
f.drop(f.c, { at: 'onto', targetId: f.a }, [f.row], [f.c, f.e]);
expect(findObject(f.sceneOf(), f.c)!.parentId).toBe(f.a);
expect(findObject(f.sceneOf(), f.e)!.parentId).toBe(f.a);
});
it('carries a selected child with its selected parent instead of on its own', () => {
const f = grouped();
f.drop(f.a, { at: 'after', targetId: f.e }, [f.row], [f.a, f.k]);
expect(findObject(f.sceneOf(), f.k)!.parentId).toBe(f.a);
expect(f.listed()).toBe('[Crates] .b .d c e a .k');
});
it('moves only the dragged row when it is not part of the selection', () => {
const f = grouped();
f.drop(f.e, { at: 'before', targetId: f.a }, [f.row], [f.c]);
expect(f.listed()).toBe('e a .k [Crates] .b .d c');
});
it('makes nothing of a drop onto or between the selected rows themselves', () => {
const f = grouped();
expect(f.drop(f.c, { at: 'onto', targetId: f.e }, [f.row], [f.c, f.e])).toBe(0);
expect(f.drop(f.a, { at: 'before', targetId: f.k }, [f.row], [f.a, f.c])).toBe(0);
expect(f.drop(f.c, { at: 'after', targetId: f.d }, [f.row], [f.c, f.e])).toBeGreaterThan(0);
f.stack.undo();
// Already side by side at the end of the top level: nothing to do.
expect(f.drop(f.c, { at: 'end' }, [f.row], [f.c, f.e])).toBe(0);
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
});
});
+72
View File
@@ -169,4 +169,76 @@ export async function hierarchyGroups(t) {
JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52i. A group "Pile" of o5 alone; its folder stands in the icon column of the objects, the
// count left of it. Then o1 and o2 join it through the dialog's group dropdown.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o5', objectIds: ['o5'] });
const lone = await rowAt('o5');
await click(lone.x, lone.y, 'right');
await clickText('Group…');
await typeText('Pile');
await press('Enter');
const pile = (await scene()).groups?.[0];
const pileRow = `group:${pile?.id}`;
const folder = await json(`__gm.hierarchyIcon(${JSON.stringify(pileRow)})`);
const count = await json(`__gm.hierarchyHint(${JSON.stringify(pileRow)})`);
const objectIcon = await json('__gm.hierarchyIcon("o1")');
check(
"a group's folder stands in the objects' icon column, its count left of it",
folder !== null &&
objectIcon !== null &&
count !== null &&
Math.abs(folder.x - objectIcon.x) < 1 &&
count.x < folder.x - 8,
JSON.stringify({ folder, objectIcon, count }),
);
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] });
const joiner = await rowAt('o1');
await click(joiner.x, joiner.y, 'right');
await clickText('Group 2 objects…');
await clickText('New group');
const choices = (await json('__gm.menuRows()')) ?? [];
// The last "Pile" drawn is the menu's, above the hierarchy row of the same name.
const pick = await json('__gm.locate("Pile", true)');
await click(pick.x, pick.y);
await shot('52-join-group');
await clickText('Add');
const joined = await scene();
check(
'the Group dialog puts the objects into an existing group picked from its dropdown',
same(choices, ['New group', 'Pile']) &&
joined.groups?.length === 1 &&
same(
joined.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id),
['o1', 'o2', 'o5'],
),
JSON.stringify({ choices, groups: joined.groups, objects: joined.objects.map((o) => [o.id, o.groupId]) }),
);
await undo();
// 52j. Dragging one row of a multi-selection takes the others along – here onto the group row.
await select({ kind: 'object', holderId: 'scene1', objectId: 'o1', objectIds: ['o1', 'o2'] });
const grab = await rowAt('o2');
const into = await rowAt(pileRow);
await drag(grab.x, grab.y, into.x, into.y);
const dragged = await scene();
const inPile = dragged.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id);
await undo();
const undone = await scene();
check(
'dragging a selected row moves the whole selection, in one undo step',
same(inPile, ['o5', 'o1', 'o2']) &&
same(
undone.objects.filter((o) => o.groupId === pile?.id).map((o) => o.id),
['o5'],
),
JSON.stringify({ inPile, undone: undone.objects.map((o) => [o.id, o.groupId]) }),
);
await undo();
check(
'undo takes the group of o5 back too',
(await scene()).groups === undefined,
JSON.stringify((await scene()).groups),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
}