From 2eeabf7c23a9394da0edbf829b7c3ec990e5beec Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 27 Sep 2026 11:19:12 +0200 Subject: [PATCH] Drag a whole selection and join existing groups in the hierarchy (#241) 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 --- README.md | 2 +- src/editor/debug/uiHooks.ts | 2 + src/editor/objectGroups.ts | 22 ++++- src/editor/panels/GroupNameDialog.ts | 59 +++++++++++-- src/editor/panels/HierarchyPanel.ts | 19 ++++- src/editor/panels/hierarchyDrop.ts | 123 +++++++++++++++++---------- src/ui/widgets/ListRow.ts | 17 ++-- src/ui/widgets/ListView.ts | 7 +- tests/editor/hierarchy-drop.test.ts | 68 ++++++++++++++- tools/e2e/52-hierarchy-groups.mjs | 72 ++++++++++++++++ 10 files changed, 327 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index fb06523d..d3f85d58 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/editor/debug/uiHooks.ts b/src/editor/debug/uiHooks.ts index af74ac3b..c9c2b126 100644 --- a/src/editor/debug/uiHooks.ts +++ b/src/editor/debug/uiHooks.ts @@ -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). */ diff --git a/src/editor/objectGroups.ts b/src/editor/objectGroups.ts index e208ff24..8a9759f7 100644 --- a/src/editor/objectGroups.ts +++ b/src/editor/objectGroups.ts @@ -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)), + }, + ), ); } diff --git a/src/editor/panels/GroupNameDialog.ts b/src/editor/panels/GroupNameDialog.ts index 351cebfc..aee4848d 100644 --- a/src/editor/panels/GroupNameDialog.ts +++ b/src/editor/panels/GroupNameDialog.ts @@ -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(); /** 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. */ diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 5a8b4a09..b1bdf072 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -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)), diff --git a/src/editor/panels/hierarchyDrop.ts b/src/editor/panels/hierarchyDrop.ts index 9e2aab09..fc5d9b7b 100644 --- a/src/editor/panels/hierarchyDrop.ts +++ b/src/editor/panels/hierarchyDrop.ts @@ -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; } diff --git a/src/ui/widgets/ListRow.ts b/src/ui/widgets/ListRow.ts index 4612275a..a3c752b3 100644 --- a/src/ui/widgets/ListRow.ts +++ b/src/ui/widgets/ListRow.ts @@ -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)); diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index 56ca4cc6..92306bcc 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -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; diff --git a/tests/editor/hierarchy-drop.test.ts b/tests/editor/hierarchy-drop.test.ts index 8f030679..1c36d0e5 100644 --- a/tests/editor/hierarchy-drop.test.ts +++ b/tests/editor/hierarchy-drop.test.ts @@ -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'); + }); +}); diff --git a/tools/e2e/52-hierarchy-groups.mjs b/tools/e2e/52-hierarchy-groups.mjs index eb73dcb7..b276ba60 100644 --- a/tools/e2e/52-hierarchy-groups.mjs +++ b/tools/e2e/52-hierarchy-groups.mjs @@ -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()'); }