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>
177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { oneStep } from '../../src/core/commands/CompoundCommand';
|
||
import { GroupObjectsCommand } from '../../src/core/commands/groupCommands';
|
||
import {
|
||
findObject,
|
||
groupMemberIds,
|
||
groupRowId,
|
||
hierarchyRows,
|
||
type HierarchyRow,
|
||
} from '../../src/core/model/hierarchy';
|
||
import { hierarchyDrop } from '../../src/editor/panels/hierarchyDrop';
|
||
import type { DropSpot } from '../../src/ui/widgets/ListView';
|
||
import { makeSpriteScene, place } from '../helpers';
|
||
|
||
/** Top-level a, b, c, d, e; b and d form the group "Crates", so the list reads a [b d] c e. */
|
||
function grouped() {
|
||
const f = makeSpriteScene();
|
||
const [a, b, c, d, e] = [0, 1, 2, 3, 4].map((i) => place(f, f.spriteId, { x: i * 10, y: 0 }));
|
||
const k = place(f, f.spriteId, { x: 0, y: 10 }, a);
|
||
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 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;
|
||
};
|
||
const label = (r: HierarchyRow) => (r.kind === 'group' ? `[${r.group.name}]` : name(r.object.id));
|
||
const names: Record<string, string> = { [a!]: 'a', [b!]: 'b', [c!]: 'c', [d!]: 'd', [e!]: 'e', [k]: 'k' };
|
||
const name = (id: string) => names[id] ?? '?';
|
||
/** The open list as text: a group's members follow it one step in. */
|
||
const listed = () =>
|
||
hierarchyRows(f.sceneOf(), new Set([row, a!]))
|
||
.map((r) => `${'.'.repeat(r.depth)}${label(r)}`)
|
||
.join(' ');
|
||
const members = () => groupMemberIds(f.sceneOf(), group.groupId).map(name);
|
||
return { ...f, a: a!, b: b!, c: c!, d: d!, e: e!, k, row, drop, listed, members };
|
||
}
|
||
|
||
describe('dropping rows in the hierarchy with groups (#241)', () => {
|
||
it('starts from a group listed as one block', () => {
|
||
const f = grouped();
|
||
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
|
||
});
|
||
|
||
it('puts an object dropped onto the group row behind its last member', () => {
|
||
const f = grouped();
|
||
f.drop(f.e, { at: 'onto', targetId: f.row });
|
||
expect(f.members()).toEqual(['b', 'd', 'e']);
|
||
expect(f.listed()).toBe('a .k [Crates] .b .d .e c');
|
||
f.stack.undo();
|
||
expect(f.listed()).toBe('a .k [Crates] .b .d c e');
|
||
});
|
||
|
||
it('joins the group between two members, and at the top just below an open group row', () => {
|
||
const f = grouped();
|
||
f.drop(f.e, { at: 'after', targetId: f.b });
|
||
expect(f.listed()).toBe('a .k [Crates] .b .e .d c');
|
||
f.drop(f.c, { at: 'after', targetId: f.row });
|
||
expect(f.listed()).toBe('a .k [Crates] .c .b .e .d');
|
||
});
|
||
|
||
it('goes behind a folded group, and in front of any group row, without joining', () => {
|
||
const f = grouped();
|
||
f.drop(f.a, { at: 'after', targetId: f.row }, []);
|
||
expect(f.listed()).toBe('[Crates] .b .d a .k c e');
|
||
f.drop(f.e, { at: 'before', targetId: f.row });
|
||
expect(f.listed()).toBe('e [Crates] .b .d a .k c');
|
||
expect(f.members()).toEqual(['b', 'd']);
|
||
});
|
||
|
||
it('takes a member out when it is dropped beside a row outside the group', () => {
|
||
const f = grouped();
|
||
f.drop(f.d, { at: 'after', targetId: f.e });
|
||
expect(f.members()).toEqual(['b']);
|
||
expect(f.listed()).toBe('a .k [Crates] .b c e d');
|
||
});
|
||
|
||
it('brings a child up to the top level when it is dropped into a group', () => {
|
||
const f = grouped();
|
||
f.drop(f.k, { at: 'before', targetId: f.d });
|
||
expect(findObject(f.sceneOf(), f.k)!.parentId).toBe('');
|
||
expect(f.listed()).toBe('a [Crates] .b .k .d c e');
|
||
});
|
||
|
||
it('takes a member out of the group when it is dropped onto another object', () => {
|
||
const f = grouped();
|
||
f.drop(f.b, { at: 'onto', targetId: f.c });
|
||
expect(findObject(f.sceneOf(), f.b)!.groupId).toBeUndefined();
|
||
expect(f.members()).toEqual(['d']);
|
||
});
|
||
|
||
it('moves a group row with all its members as one block', () => {
|
||
const f = grouped();
|
||
f.drop(f.row, { at: 'after', targetId: f.e });
|
||
expect(f.listed()).toBe('a .k c e [Crates] .b .d');
|
||
f.drop(f.row, { at: 'before', targetId: f.a });
|
||
expect(f.listed()).toBe('[Crates] .b .d a .k c e');
|
||
// One undo takes the whole block back.
|
||
f.stack.undo();
|
||
expect(f.listed()).toBe('a .k c e [Crates] .b .d');
|
||
});
|
||
|
||
it('makes nothing of a drop that leaves everything where it is', () => {
|
||
const f = grouped();
|
||
expect(f.drop(f.d, { at: 'after', targetId: f.b })).toBe(0);
|
||
expect(f.drop(f.b, { at: 'onto', targetId: f.row })).toBe(0);
|
||
expect(f.drop(f.row, { at: 'before', targetId: f.c })).toBe(0);
|
||
expect(f.drop(f.row, { at: 'onto', targetId: f.c })).toBe(0);
|
||
expect(f.drop(f.row, { at: 'before', targetId: f.k })).toBe(0);
|
||
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');
|
||
});
|
||
});
|