diff --git a/README.md b/README.md index e2963fe1..fb06523d 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, 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 diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts index 8f693f35..5a8b4a09 100644 --- a/src/editor/panels/HierarchyPanel.ts +++ b/src/editor/panels/HierarchyPanel.ts @@ -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. diff --git a/src/editor/panels/hierarchyRange.ts b/src/editor/panels/hierarchyRange.ts new file mode 100644 index 00000000..112835c7 --- /dev/null +++ b/src/editor/panels/hierarchyRange.ts @@ -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)]; +} diff --git a/tests/editor/hierarchy-range.test.ts b/tests/editor/hierarchy-range.test.ts new file mode 100644 index 00000000..f6ee9172 --- /dev/null +++ b/tests/editor/hierarchy-range.test.ts @@ -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 = { [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(''); + }); +}); diff --git a/tools/e2e/52-hierarchy-groups.mjs b/tools/e2e/52-hierarchy-groups.mjs index 5c8e9f68..eb73dcb7 100644 --- a/tools/e2e/52-hierarchy-groups.mjs +++ b/tools/e2e/52-hierarchy-groups.mjs @@ -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()'); }