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 <noreply@anthropic.com>
This commit is contained in:
1 parent
893b81f798
commit
2eeabf7c23
10 files changed
+327
-64
No files matched your search
@@ -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
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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()');
|
||||
}
|
||||
Reference in new issue
Block a user