Select a range of rows with shift-click in the hierarchy (#241)
game-maker / gate (push) Successful in 3m21s
game-maker / suite (push) Failing after 9m5s

Ctrl- and shift-click only added single rows to the selection. With
many objects spread over the hierarchy, picking a run of them meant
clicking each one. Shift-click now selects everything between the
anchor and the clicked row, as in a file explorer.

- The anchor is the row last clicked without shift, plain or with
  ctrl. A second shift-click redraws the range from the same anchor.
- Ctrl+shift-click adds the range to the current selection instead
  of replacing it.
- A collapsed group inside the range brings all its members. Children
  hidden under a collapsed parent stay out, since they are not shown.
- Without an anchor from the hierarchy, the primary object picked in
  the scene view serves as anchor. With no selection at all,
  shift-click adds the single row as before.
- The range rule is a pure function in hierarchyRange.ts, so it is
  tested without a browser. HierarchyPanel keeps the anchor.

Not checked in the browser: ctrl+shift-click and the anchor taken from
the scene view. Only the code path covers them.

Tests:
- ./check.sh: 178 files, 2557 tests, CHECK EXIT 0
- e2e 1 + 52: ALL PASSED, 23 checks
- 5 new unit tests in tests/editor/hierarchy-range.test.ts
- New e2e check 52h with real clicks
- Counter-check: without the range, 52h fails with ["o2","o5"]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 10:53:57 +02:00
1 parent 53fa63441b
commit 893b81f798
5 files changed
+149 -1

No files matched your search

+1 -1
View File
@@ -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, 1442 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1443 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
+44
View File
@@ -17,6 +17,7 @@ import { groupRowItems, selectGroup } from '../objectGroups';
import { openObjectMenu } from '../objectMenu';
import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons';
import { hierarchyDrop } from './hierarchyDrop';
import { hierarchyRange } from './hierarchyRange';
import { type EditorState, objectSelection } from '../state';
/**
@@ -36,6 +37,8 @@ export class HierarchyPanel extends Panel {
private source: () => ObjectHolder | undefined = () => this.state.activeScene;
/** Id of the holder the rows were built from, to notice when the source starts naming another. */
private shownHolderId = '';
/** Row the last click without Shift landed on – where a Shift-click range starts. */
private anchor: string | null = null;
constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
@@ -44,6 +47,8 @@ export class HierarchyPanel extends Panel {
this.list.onSelect = (id, modifiers) => {
const holder = this.holder();
if (!holder) return;
if (id && modifiers?.shift && this.selectRange(holder, id, modifiers.ctrl)) return;
this.anchor = id;
const groupId = id && groupIdOfRow(id);
const toggle = modifiers && (modifiers.ctrl || modifiers.shift);
if (groupId && toggle) this.toggleGroup(holder.id, groupId);
@@ -96,6 +101,45 @@ export class HierarchyPanel extends Panel {
this.state.select(objectSelection(holderId, next));
}
/**
* Shift-click: the rows from the anchor to the clicked one replace the selection, with Ctrl
* held they join it. The anchor stays, so a second Shift-click redraws the range from the same
* row. Returns false when there is no anchor to start from – the click then toggles as before.
*/
private selectRange(holder: ObjectHolder, clickedRowId: string, add: boolean): boolean {
const rows = this.rowOrder();
const anchor = this.anchorRow(holder, rows);
if (!anchor) return false;
const range = hierarchyRange(holder, rows, anchor, clickedRowId);
if (range.length === 0) return false;
const groupId = groupIdOfRow(clickedRowId);
const primary = groupId ? groupMemberIds(holder, groupId)[0] : clickedRowId;
const current = add ? this.state.selectedObjectIds : [];
this.anchor = anchor;
this.state.select(objectSelection(holder.id, [...new Set([...current, ...range])], primary));
return true;
}
/**
* The remembered anchor while it is shown and still selected; otherwise the row of the primary
* selection – which may come from the scene view – or its folded group's row.
*/
private anchorRow(holder: ObjectHolder, rows: readonly string[]): string | null {
const selected = this.state.selectedObjectIds;
const anchor = this.anchor;
if (anchor && rows.includes(anchor)) {
const groupId = groupIdOfRow(anchor);
const ids = groupId ? groupMemberIds(holder, groupId) : [anchor];
if (ids.some((id) => selected.includes(id))) return anchor;
}
const s = this.state.selection;
const primary = s?.kind === 'object' && s.holderId === holder.id ? s.objectId : null;
if (!primary) return null;
if (rows.includes(primary)) return primary;
const groupId = findObject(holder, primary)?.groupId;
return groupId && rows.includes(groupRowId(groupId)) ? groupRowId(groupId) : null;
}
/**
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
* active scene again.
+24
View File
@@ -0,0 +1,24 @@
import { groupIdOfRow, groupMemberIds, type ObjectTree } from '../../core/model/hierarchy';
/**
* The objects a Shift-click selects: every shown row from the anchor to the clicked one, both
* included, in either direction. A group row stands for all its members, so a folded group in the
* range comes in whole; objects folded away under a parent stay out, as they are not shown.
* Empty when either row is not shown.
*/
export function hierarchyRange(
tree: ObjectTree,
rowIds: readonly string[],
anchorRowId: string,
clickedRowId: string,
): string[] {
const from = rowIds.indexOf(anchorRowId);
const to = rowIds.indexOf(clickedRowId);
if (from < 0 || to < 0) return [];
const rows = rowIds.slice(Math.min(from, to), Math.max(from, to) + 1);
const ids = rows.flatMap((rowId) => {
const groupId = groupIdOfRow(rowId);
return groupId ? groupMemberIds(tree, groupId) : [rowId];
});
return [...new Set(ids)];
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { GroupObjectsCommand } from '../../src/core/commands/groupCommands';
import { groupRowId, hierarchyRows } from '../../src/core/model/hierarchy';
import { hierarchyRange } from '../../src/editor/panels/hierarchyRange';
import { makeSpriteScene, place } from '../helpers';
/** Top-level a, b, c, d, e with k under a; b and d form the group "Crates": 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);
const names: Record<string, string> = { [a!]: 'a', [b!]: 'b', [c!]: 'c', [d!]: 'd', [e!]: 'e', [k]: 'k' };
/** The row ids the panel shows with these rows unfolded. */
const rows = (open: string[]) =>
hierarchyRows(f.sceneOf(), new Set(open)).map((r) =>
r.kind === 'group' ? groupRowId(r.group.id) : r.object.id,
);
const range = (open: string[], anchor: string, clicked: string) =>
hierarchyRange(f.sceneOf(), rows(open), anchor, clicked)
.map((id) => names[id] ?? '?')
.sort()
.join(' ');
return { a: a!, b: b!, c: c!, d: d!, e: e!, k, row, range };
}
describe('Shift-click range in the hierarchy', () => {
it('takes every shown row between anchor and click, in either direction', () => {
const f = grouped();
expect(f.range([f.a, f.row], f.a, f.c)).toBe('a b c d k');
expect(f.range([f.a, f.row], f.c, f.a)).toBe('a b c d k');
expect(f.range([f.a, f.row], f.b, f.b)).toBe('b');
});
it('brings in all members of a folded group, but no children folded under a parent', () => {
const f = grouped();
expect(f.range([], f.a, f.c)).toBe('a b c d');
});
it('starts inside an open group and runs out of it', () => {
const f = grouped();
expect(f.range([f.row], f.d, f.e)).toBe('c d e');
});
it('can start or end on a group row', () => {
const f = grouped();
expect(f.range([], f.row, f.e)).toBe('b c d e');
expect(f.range([f.row], f.a, f.row)).toBe('a b d');
});
it('gives nothing when a row is not shown', () => {
const f = grouped();
expect(f.range([], f.k, f.c)).toBe('');
});
});
+23
View File
@@ -146,4 +146,27 @@ export async function hierarchyGroups(t) {
JSON.stringify({ narrowedToOther, narrowedToPrimary }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
// 52h. Shift-click takes every row from the last plain click to the clicked one; a second
// Shift-click redraws the range from the same anchor, and it runs upwards just as well.
// It starts on o2: 52g's last click hit o1, and a second one there would count as a double click.
const clickRow = async (id, modifiers) => {
const at = await rowAt(id);
await click(at.x, at.y, 'left', modifiers);
};
await clickRow('o2');
await clickRow('o5', { shift: true });
const down = await selected();
const primaryAfter = (await json('__gm.state.selection'))?.objectId;
await clickRow('o1', { shift: true });
const up = await selected();
check(
'Shift-click selects the rows in between',
same([...down].sort(), ['o2', 'o3', 'o5']) &&
primaryAfter === 'o5' &&
same([...up].sort(), ['o1', 'o2']) &&
same(await rowIds(), ['o1', 'o2', 'o3', 'o5']),
JSON.stringify({ down, primaryAfter, up, rows: await rowIds() }),
);
await evaluate('__gm.state.select(null); __gm.hierarchyCollapseAll()');
}