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

+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');
});
});