Select a range of rows with shift-click in the hierarchy (#241)
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:
1 parent
53fa63441b
commit
893b81f798
5 files changed
+149
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
Reference in new issue
Block a user