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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user