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

+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. */