Add pixel-size fit and drag-to-reorder in the hierarchy

The aspect button now carries two offers, so it becomes a menu: fit one
side to the texture ratio, or set both sides to the exact pixel size as a
single undo step.

Dropping a row on the outer quarter of another row inserts it into that
gap instead of parenting into it, reordering among the siblings there.
Order is the stored order, so ReorderObjectCommand is a splice; equal z
falls back to tree order, which makes this the way to draw one object in
front of another. Rejected on container content like the other structural
edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 22:20:46 +02:00
1 parent f40b8a0523
commit 4568c6581b
17 files changed
+416 -85

No files matched your search

+48 -13
View File
@@ -1,19 +1,22 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
* another to parent it there, into the gap above or below a row to order it among that row's
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
* its world position every time.
*
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
* Everything starts collapsed – which ids are open is view state kept here, never part of the
@@ -42,14 +45,7 @@ export class HierarchyPanel extends Panel {
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
else state.select(id ? objectSelection(holder.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
};
this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
@@ -72,6 +68,45 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
/**
* A drop either puts the dragged row inside another one (parenting, which keeps the world
* position) or into a gap, which orders it among the siblings there – and does both when the
* gap belongs to a parent the object did not have. A move that would leave the order as it is
* does nothing, so a drag that lands where it started 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);
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands));
}
/**
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
* active scene again.