Group scene objects in the hierarchy, for the editor only (#241)
A scene with a hundred crates fills the hierarchy with a hundred rows. Groups bundle top-level objects under one folder row that folds away. They exist for the editor only. Parenting, positions and the game do not see them. - Model: a scene or container template holds a list of groups, an object may carry a groupId. Old projects load unchanged. Loading drops empty groups and members that are no longer top level. - Only top-level objects can join a group. Parenting a member takes it out of the group, and Undo puts it back. There are no groups in groups. - Order: treeOrder lists the members together, where the first one stands. The same order decides drawing at equal z, in the editor and in the game, so the hierarchy and the picture cannot disagree. The stored object list stays untouched when grouping. - Hierarchy: a group row shows a folder icon, the name and the member count. Clicking it selects all members and keeps it folded. Selecting a single member elsewhere unfolds it. Folding lasts for the session. - Create with "Group..." in the object menu. A name dialog asks first, so creating is one Undo step and Cancel creates nothing. - The group menu has Select members, Rename... and Ungroup. The object menu adds "Remove from group". - Drag and drop: onto the group row appends, into a gap between members inserts there, next to a row outside leaves the group. Dragging the group row moves all members as a block. The drop rules live in hierarchyDrop.ts as a pure function. - Copy and paste keeps a top-level copy in the group of its original. A container built from group members takes their place in the group. - Clicking an object that is already selected together with others now keeps only that object selected. ListView narrows the selection on release, so a drag starting on such a row keeps the selection. Not checked in the browser: groups in the container tab, "Remove from group", Ctrl-click on a group row and a drag out of a multi-selection. The code path covers them. Tests: - check.sh: 177 files, 2552 tests, CHECK EXIT 0 - e2e 1, 51, 52: ALL PASSED, 31 checks (new module 52) - 19 new unit tests in object-groups.test.ts and hierarchy-drop.test.ts - 9 counter-probes, all fail as expected (7 unit, 2 in the browser) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b89292eb4
commit
53fa63441b
26 files changed
+1174
-83
No files matched your search
@@ -2,6 +2,7 @@ import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import { Button, Label, Panel, TextField, Widget, activeDrag, currentMenu, ui } from '../../ui';
|
||||
import { Theme } from '../../ui/Theme';
|
||||
import { currentFontPicker } from '../font/FontPicker';
|
||||
import { currentGroupDialog } from '../panels/GroupNameDialog';
|
||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||
import { rectOf, textProbe } from '../textProbe';
|
||||
import { currentImagePicker } from '../texture/ImagePicker';
|
||||
@@ -299,5 +300,7 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
hierarchyIcon: (id: string) => hierarchy.iconPosition(id),
|
||||
hierarchyExpandAll: () => hierarchy.expandAll(),
|
||||
hierarchyCollapseAll: () => hierarchy.collapseAll(),
|
||||
/** Whether the dialog naming a hierarchy group is open (#241). */
|
||||
groupDialogOpen: () => currentGroupDialog() !== null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
GroupObjectsCommand,
|
||||
RenameGroupCommand,
|
||||
SetObjectGroupCommand,
|
||||
UngroupCommand,
|
||||
} from '../core/commands/groupCommands';
|
||||
import { nextFreeName } from '../core/commands/naming';
|
||||
import { canGroup, findGroup, findObject, groupMemberIds } from '../core/model/hierarchy';
|
||||
import type { ObjectHolder } from '../core/model/Project';
|
||||
import type { MenuEntry, MenuItem } from '../ui';
|
||||
import { GroupNameDialog } from './panels/GroupNameDialog';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
/** "Group", "Group 2", … – free among the groups that are listed. */
|
||||
function nextGroupName(holder: ObjectHolder): string {
|
||||
const listed = (holder.groups ?? []).filter((g) => groupMemberIds(holder, g.id).length > 0);
|
||||
return nextFreeName(
|
||||
listed.map((g) => g.name),
|
||||
'Group',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The object menu's entries for groups (#241). `source` is the stored holder: content of a placed
|
||||
* container is a child, so it can never be grouped.
|
||||
*/
|
||||
export function groupMenuItems(
|
||||
state: EditorState,
|
||||
source: ObjectHolder,
|
||||
selected: readonly string[],
|
||||
): MenuItem[] {
|
||||
const many = selected.length > 1;
|
||||
const grouped = selected.filter((id) => findGroup(source, findObject(source, id)?.groupId ?? ''));
|
||||
return [
|
||||
{
|
||||
label: many ? `Group ${selected.length} objects…` : 'Group…',
|
||||
disabled: !canGroup(source, selected),
|
||||
onSelect: () => groupObjects(state, source, selected),
|
||||
},
|
||||
{
|
||||
label: 'Remove from group',
|
||||
disabled: grouped.length === 0,
|
||||
onSelect: () => state.execute(new SetObjectGroupCommand(source.id, grouped, '')),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function groupObjects(state: EditorState, source: ObjectHolder, ids: readonly string[]): void {
|
||||
const title = ids.length > 1 ? `Group ${ids.length} objects` : 'Group the object';
|
||||
GroupNameDialog.open(
|
||||
new GroupNameDialog(title, nextGroupName(source), 'Group', (name) => {
|
||||
state.execute(new GroupObjectsCommand(source.id, ids, name));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The menu of a group's row in the hierarchy; `extras` go below, where the panel adds folding. */
|
||||
export function groupRowItems(
|
||||
state: EditorState,
|
||||
source: ObjectHolder,
|
||||
groupId: string,
|
||||
extras: readonly MenuItem[],
|
||||
): MenuEntry[] {
|
||||
const group = findGroup(source, groupId);
|
||||
if (!group) return [...extras];
|
||||
return [
|
||||
{
|
||||
label: 'Select members',
|
||||
onSelect: () => selectGroup(state, source, groupId),
|
||||
},
|
||||
{
|
||||
label: 'Rename…',
|
||||
onSelect: () =>
|
||||
GroupNameDialog.open(
|
||||
new GroupNameDialog(`Rename group "${group.name}"`, group.name, 'Rename', (name) => {
|
||||
if (name !== group.name) state.execute(new RenameGroupCommand(source.id, groupId, name));
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Ungroup',
|
||||
onSelect: () => state.execute(new UngroupCommand(source.id, groupId)),
|
||||
},
|
||||
{ separator: true },
|
||||
...extras,
|
||||
];
|
||||
}
|
||||
|
||||
export function selectGroup(state: EditorState, source: ObjectHolder, groupId: string): void {
|
||||
const members = groupMemberIds(source, groupId);
|
||||
state.select(objectSelection(source.id, members, members[0]));
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './actions';
|
||||
import { hasAssetEditor } from './assetTabs';
|
||||
import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard';
|
||||
import { groupMenuItems } from './objectGroups';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/**
|
||||
@@ -112,6 +113,7 @@ export function openObjectMenu(
|
||||
disabled: !object?.parentId || many || fromTemplate,
|
||||
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
|
||||
},
|
||||
...groupMenuItems(state, source, selected),
|
||||
...extras,
|
||||
{
|
||||
label: many
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { TextField, ui } from '../../ui';
|
||||
import { dialogSlot, FormDialog } from '../FormDialog';
|
||||
|
||||
const FIELD_WIDTH = 220;
|
||||
|
||||
const slot = dialogSlot<GroupNameDialog>();
|
||||
|
||||
/** The open group name dialog, for the check scripts. */
|
||||
export const currentGroupDialog = slot.current;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class GroupNameDialog extends FormDialog {
|
||||
private readonly field: TextField;
|
||||
|
||||
constructor(title: string, name: string, confirm: string, onName: (name: string) => void) {
|
||||
super(320, title);
|
||||
const accept = () => onName(this.field.value.trim() || name);
|
||||
this.field = new TextField(name, {
|
||||
width: FIELD_WIDTH,
|
||||
onSubmit: () => {
|
||||
this.close();
|
||||
accept();
|
||||
},
|
||||
});
|
||||
this.content.add(this.formRow(this.fieldLabel('Name'), this.field));
|
||||
this.addButtons(confirm, 'primary', accept);
|
||||
}
|
||||
|
||||
/** Opens the dialog centred with the name selected. */
|
||||
static open(dialog: GroupNameDialog): GroupNameDialog {
|
||||
slot.open(dialog);
|
||||
ui().focus.focus(dialog.field);
|
||||
return dialog;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,33 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||||
import {
|
||||
ancestorIdsOf,
|
||||
findGroup,
|
||||
findObject,
|
||||
groupIdOfRow,
|
||||
groupMemberIds,
|
||||
groupRowId,
|
||||
hierarchyRows,
|
||||
type HierarchyRow,
|
||||
} from '../../core/model/hierarchy';
|
||||
import { resolvedHolder } from '../../core/model/instance';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { deviationMarks } from '../../core/properties/deviation';
|
||||
import { type DropSpot, ListView, type Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import { groupRowItems, selectGroup } from '../objectGroups';
|
||||
import { openObjectMenu } from '../objectMenu';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { ASSET_ICON_SVG, GROUP_ICON_SVG } from './assetIcons';
|
||||
import { hierarchyDrop } from './hierarchyDrop';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
/**
|
||||
* Objects of the scene or container being edited, as a tree; dragging re-parents or reorders and always
|
||||
* keeps world position. Fold state is view state kept here, never in the project.
|
||||
* keeps world position. Groups (#241) are rows of their own over their members. Fold state is view
|
||||
* state kept here, never in the project.
|
||||
*/
|
||||
export class HierarchyPanel extends Panel {
|
||||
private readonly list = new ListView();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
|
||||
/** Ids of the objects and group rows whose content is shown. Anything missing is collapsed. */
|
||||
private readonly expanded = new Set<string>();
|
||||
/**
|
||||
* Which objects are listed. A function, not an id, so a container renamed or swapped under the
|
||||
@@ -34,7 +44,11 @@ export class HierarchyPanel extends Panel {
|
||||
this.list.onSelect = (id, modifiers) => {
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
|
||||
const groupId = id && groupIdOfRow(id);
|
||||
const toggle = modifiers && (modifiers.ctrl || modifiers.shift);
|
||||
if (groupId && toggle) this.toggleGroup(holder.id, groupId);
|
||||
else if (groupId) selectGroup(state, holder, groupId);
|
||||
else if (id && toggle) state.toggleObject(holder.id, id);
|
||||
else state.select(id ? objectSelection(holder.id, [id]) : null);
|
||||
};
|
||||
this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
|
||||
@@ -63,37 +77,25 @@ export class HierarchyPanel extends Panel {
|
||||
/** 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();
|
||||
const object = holder && findObject(holder, draggedId);
|
||||
if (!holder || !object) return;
|
||||
if (spot.at === 'onto') {
|
||||
if (object.parentId === spot.targetId) return;
|
||||
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
|
||||
this.expanded.add(spot.targetId);
|
||||
this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId));
|
||||
return;
|
||||
}
|
||||
let parentId = '';
|
||||
let beforeId = '';
|
||||
if (spot.at !== 'end') {
|
||||
const target = findObject(holder, spot.targetId);
|
||||
if (!target) return;
|
||||
parentId = target.parentId;
|
||||
const order = childIdsOf(holder, parentId);
|
||||
beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? '');
|
||||
}
|
||||
if (beforeId === draggedId) return;
|
||||
const siblings = childIdsOf(holder, parentId);
|
||||
const rest = siblings.filter((id) => id !== draggedId);
|
||||
const at = beforeId ? rest.indexOf(beforeId) : rest.length;
|
||||
const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
|
||||
if (object.parentId === parentId && wanted.join() === siblings.join()) return;
|
||||
const commands: Command[] = [];
|
||||
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
|
||||
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
|
||||
if (parentId) this.expanded.add(parentId);
|
||||
if (!holder) return;
|
||||
const { commands, unfold } = hierarchyDrop(holder, draggedId, spot, (rowId) => this.expanded.has(rowId));
|
||||
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));
|
||||
}
|
||||
|
||||
/** Ctrl-click on a group row: all members join the selection, or leave it when all were in. */
|
||||
private toggleGroup(holderId: string, groupId: string): void {
|
||||
const holder = this.holder();
|
||||
if (!holder) return;
|
||||
const members = groupMemberIds(holder, groupId);
|
||||
const current = this.state.selectedObjectIds;
|
||||
const all = members.every((id) => current.includes(id));
|
||||
const next = all ? current.filter((id) => !members.includes(id)) : [...new Set([...current, ...members])];
|
||||
this.state.select(objectSelection(holderId, next));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
|
||||
* active scene again.
|
||||
@@ -112,7 +114,11 @@ export class HierarchyPanel extends Panel {
|
||||
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
|
||||
rowOrder(): string[] {
|
||||
const holder = this.holder();
|
||||
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
|
||||
return holder ? hierarchyRows(holder, this.expanded).map((row) => this.rowId(row)) : [];
|
||||
}
|
||||
|
||||
private rowId(row: HierarchyRow): string {
|
||||
return row.kind === 'group' ? groupRowId(row.group.id) : row.object.id;
|
||||
}
|
||||
|
||||
/** Label colour of a row as `#rrggbb` – gold means it or something below it deviates (test hook). */
|
||||
@@ -136,7 +142,9 @@ export class HierarchyPanel extends Panel {
|
||||
}
|
||||
|
||||
expandAll(): void {
|
||||
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
|
||||
const holder = this.holder();
|
||||
for (const object of holder?.objects ?? []) this.expanded.add(object.id);
|
||||
for (const group of holder?.groups ?? []) this.expanded.add(groupRowId(group.id));
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
@@ -151,10 +159,15 @@ export class HierarchyPanel extends Panel {
|
||||
const source = this.source();
|
||||
const holder = this.holder();
|
||||
if (!source || !holder) return undefined;
|
||||
return openObjectMenu(this.state, source, holder, id, x, y, [
|
||||
const folding = [
|
||||
{ label: 'Expand all', onSelect: () => this.expandAll() },
|
||||
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
|
||||
]);
|
||||
];
|
||||
const groupId = groupIdOfRow(id);
|
||||
if (!groupId) return openObjectMenu(this.state, source, holder, id, x, y, folding);
|
||||
const menu = new Menu(groupRowItems(this.state, source, groupId, folding));
|
||||
menu.open(x, y);
|
||||
return menu;
|
||||
}
|
||||
|
||||
private setExpanded(id: string, expand: boolean): void {
|
||||
@@ -163,17 +176,28 @@ export class HierarchyPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
|
||||
/**
|
||||
* Unfolds the ancestors and the group of every selected object – but not a group whose members
|
||||
* are all selected: its own row shows that. Returns whether that changed anything.
|
||||
*/
|
||||
private revealSelection(): boolean {
|
||||
const holder = this.holder();
|
||||
if (!holder) return false;
|
||||
const selected = this.state.selectedObjectIds;
|
||||
const unfold = new Set<string>();
|
||||
for (const id of selected) {
|
||||
const ancestors = ancestorIdsOf(holder, id);
|
||||
for (const ancestorId of ancestors) unfold.add(ancestorId);
|
||||
const top = findObject(holder, ancestors.at(-1) ?? id);
|
||||
if (!top?.groupId || !findGroup(holder, top.groupId)) continue;
|
||||
if (!groupMemberIds(holder, top.groupId).every((m) => selected.includes(m)))
|
||||
unfold.add(groupRowId(top.groupId));
|
||||
}
|
||||
let changed = false;
|
||||
for (const id of this.state.selectedObjectIds) {
|
||||
for (const ancestorId of ancestorIdsOf(holder, id)) {
|
||||
if (this.expanded.has(ancestorId)) continue;
|
||||
this.expanded.add(ancestorId);
|
||||
changed = true;
|
||||
}
|
||||
for (const rowId of unfold) {
|
||||
if (this.expanded.has(rowId)) continue;
|
||||
this.expanded.add(rowId);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -188,7 +212,18 @@ export class HierarchyPanel extends Panel {
|
||||
const source = this.source();
|
||||
const marks = source ? deviationMarks(this.state.project, source) : new Set<string>();
|
||||
this.list.setItems(
|
||||
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
|
||||
(holder ? hierarchyRows(holder, this.expanded) : []).map((row) => {
|
||||
if (row.kind === 'group')
|
||||
return {
|
||||
id: groupRowId(row.group.id),
|
||||
label: row.group.name || 'Group',
|
||||
hint: String(row.memberIds.length),
|
||||
icon: GROUP_ICON_SVG,
|
||||
depth: 0,
|
||||
expanded: this.expanded.has(groupRowId(row.group.id)),
|
||||
overridden: row.memberIds.some((id) => marks.has(id)),
|
||||
};
|
||||
const { object, depth, hasChildren } = row;
|
||||
const asset = findAsset(this.state.project, object.assetId);
|
||||
return {
|
||||
id: object.id,
|
||||
@@ -206,17 +241,28 @@ export class HierarchyPanel extends Panel {
|
||||
this.scrollToSelection();
|
||||
}
|
||||
|
||||
/** A group's row is lit when all its members are selected. */
|
||||
private syncSelection(): void {
|
||||
const s = this.state.selection;
|
||||
const holder = this.holder();
|
||||
const selected = s?.kind === 'object' ? s.objectIds : [];
|
||||
const groups = (holder?.groups ?? []).filter((g) => {
|
||||
const members = groupMemberIds(holder!, g.id);
|
||||
return members.length > 0 && members.every((id) => selected.includes(id));
|
||||
});
|
||||
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
|
||||
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
|
||||
this.list.highlight([...selected, ...groups.map((g) => groupRowId(g.id))]);
|
||||
}
|
||||
|
||||
/** Keeps the primary selection in view after unfolding brought it back into the list. */
|
||||
/** Keeps the primary selection – or its folded group's row – in view after unfolding. */
|
||||
private scrollToSelection(): void {
|
||||
const s = this.state.selection;
|
||||
if (s?.kind !== 'object' || !s.objectId) return;
|
||||
const index = this.rowOrder().indexOf(s.objectId);
|
||||
const rows = this.rowOrder();
|
||||
const top = this.holder() && findObject(this.holder()!, s.objectId);
|
||||
const index = rows.includes(s.objectId)
|
||||
? rows.indexOf(s.objectId)
|
||||
: rows.indexOf(groupRowId(top?.groupId ?? ''));
|
||||
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import collider from './icons/collider.svg?raw';
|
||||
import container from './icons/container.svg?raw';
|
||||
import font from './icons/font.svg?raw';
|
||||
import game from './icons/game.svg?raw';
|
||||
import group from './icons/group.svg?raw';
|
||||
import list from './icons/list.svg?raw';
|
||||
import palette from './icons/palette.svg?raw';
|
||||
import particles from './icons/particles.svg?raw';
|
||||
@@ -56,6 +57,9 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
|
||||
/** The List tab's: it edits a list value, which is no asset. */
|
||||
export const LIST_ICON_SVG = list;
|
||||
|
||||
/** A group's row in the hierarchy (#241): a folder, as it only sorts what is listed. */
|
||||
export const GROUP_ICON_SVG = group;
|
||||
|
||||
/** The Game tab's: it runs the project, so neither is it an asset. The Scene tab takes `scene`. */
|
||||
export const GAME_ICON_SVG = game;
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { SetObjectGroupCommand } from '../../core/commands/groupCommands';
|
||||
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import {
|
||||
childIdsOf,
|
||||
findGroup,
|
||||
findObject,
|
||||
groupIdOfRow,
|
||||
groupMemberIds,
|
||||
groupRowId,
|
||||
type ObjectTree,
|
||||
treeOrder,
|
||||
} from '../../core/model/hierarchy';
|
||||
import type { DropSpot } from '../../ui/widgets/ListView';
|
||||
|
||||
export interface HierarchyDrop {
|
||||
/** Empty when the drop changes nothing – a drag back to its start costs no undo step. */
|
||||
commands: Command[];
|
||||
/** Rows to unfold so the dropped row stays in sight. */
|
||||
unfold: string[];
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
export function hierarchyDrop(
|
||||
holder: ObjectTree & { id: string },
|
||||
draggedId: string,
|
||||
spot: DropSpot,
|
||||
isOpen: (rowId: string) => boolean,
|
||||
): HierarchyDrop {
|
||||
const groupId = groupIdOfRow(draggedId);
|
||||
return groupId ? moveGroup(holder, groupId, spot) : moveObject(holder, draggedId, 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.
|
||||
*/
|
||||
function moveObject(
|
||||
holder: ObjectTree & { id: string },
|
||||
draggedId: 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 targetGroup = spot.at === 'end' ? null : groupIdOfRow(spot.targetId);
|
||||
const target = spot.at === 'end' || targetGroup ? undefined : findObject(holder, spot.targetId);
|
||||
if (spot.at === 'onto' && target) {
|
||||
if (object.parentId === target.id) return NOTHING;
|
||||
return { commands: [new SetParentCommand(holder.id, draggedId, target.id)], unfold: [target.id] };
|
||||
}
|
||||
if (targetGroup) {
|
||||
const members = groupMemberIds(holder, targetGroup);
|
||||
if (spot.at === 'onto' && inGroup === 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) ?? '');
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
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));
|
||||
const unfold = [...(parentId ? [parentId] : []), ...(groupId ? [groupRowId(groupId)] : [])];
|
||||
return { commands, unfold };
|
||||
}
|
||||
|
||||
/** A group row goes between top-level rows as one block; it never goes inside anything. */
|
||||
function moveGroup(holder: ObjectTree & { id: string }, groupId: string, spot: DropSpot): HierarchyDrop {
|
||||
const members = groupMemberIds(holder, groupId);
|
||||
let beforeId = '';
|
||||
if (spot.at !== 'end') {
|
||||
if (spot.at === 'onto') return NOTHING;
|
||||
const otherGroup = groupIdOfRow(spot.targetId);
|
||||
const target = otherGroup ? undefined : findObject(holder, spot.targetId);
|
||||
if (otherGroup) {
|
||||
const theirs = groupMemberIds(holder, otherGroup);
|
||||
beforeId = spot.at === 'before' ? (theirs[0] ?? '') : topLevelAfter(holder, theirs.at(-1) ?? '');
|
||||
} else if (target && !target.parentId && !findGroup(holder, target.groupId ?? '')) {
|
||||
beforeId = spot.at === 'before' ? target.id : topLevelAfter(holder, target.id);
|
||||
} else return NOTHING;
|
||||
}
|
||||
if (members.includes(beforeId) || topLevelAfter(holder, members.at(-1) ?? '') === beforeId) return NOTHING;
|
||||
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)
|
||||
.filter((e) => e.depth === 0)
|
||||
.map((e) => e.object.id);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
|
||||
<path d="M-6.75 -4.75 H-1.5 L0 -3.25 H6.75 V5.25 H-6.75 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 234 B |
Reference in new issue
Block a user