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

+49 -11
View File
@@ -1,29 +1,58 @@
import type { Command } from '../../core/commands/Command';
import { CompoundCommand } from '../../core/commands/CompoundCommand';
import type { Asset } from '../../core/model/Asset';
import type { PropertyBag } from '../../core/model/Properties';
import {
fitAxisToAspect,
matchesAspect,
matchesPixelSize,
type PixelSize,
type SizeAxis,
texturePixelSize,
} from '../../core/properties/aspect';
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import type { FieldActionGroup } from './fieldFactory';
import type { FieldAction, FieldActionGroup } from './fieldFactory';
import type { InspectorHost } from './host';
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
/** What a size action writes: one side for a fit, both for the picture's pixel size. */
export type SizeValues = Partial<Record<SizeAxis, number>>;
/** One undo step whether the action wrote one side or both. */
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands);
}
/** The pixel size as the schema would store it – null where a min/max would change a side. */
function storablePixelSize(asset: Asset, size: PixelSize): SizeValues | null {
const schema = schemaForAsset(asset);
const values: SizeValues = {};
for (const axis of ['width', 'height'] as const) {
const def = schema.find((d) => d.key === axis);
if (!def) return null;
const wanted = axis === 'width' ? size.w : size.h;
const next = Number(coerceValue(def, wanted));
if (next !== wanted) return null;
values[axis] = next;
}
return values;
}
/**
* "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also
* wears a texture. Empty for every other row, and – through an action list that comes back
* empty – while no texture or frame is assigned, so the button is not drawn at all.
* The size actions of the Width/Height rows of an asset that also wears a texture: fit this side
* to the picture's proportions, or set both sides to its pixel size. Empty for every other row,
* and – through an action list that comes back empty – while no texture or frame is assigned, so
* the button is not drawn at all.
*/
export function aspectActions(
host: InspectorHost,
asset: Asset,
def: PropertyDef,
props: () => PropertyBag | null,
commit: (value: number) => void,
commit: (values: SizeValues) => void,
): FieldActionGroup[] {
if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return [];
// A size without a picture (collider, camera) has no proportions to fit to.
@@ -35,14 +64,23 @@ export function aspectActions(
available: () => {
const bag = props();
const size = bag && texturePixelSize(host.project(), bag);
if (!bag || !size || matchesAspect(size, bag)) return [];
const fitted = fitAxisToAspect(size, bag, axis);
if (fitted === null) return [];
if (!bag || !size) return [];
const actions: FieldAction[] = [];
const fitted = matchesAspect(size, bag) ? null : fitAxisToAspect(size, bag, axis);
// Offered only when the schema would store it unchanged: a value its min/max clamps
// cannot make the sprite proportional, so the button would never go away again.
const next = Number(coerceValue(def, fitted));
if (next !== fitted) return [];
return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }];
const next = fitted === null ? null : Number(coerceValue(def, fitted));
if (next !== null && next === fitted) {
actions.push({ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit({ [axis]: next }) });
}
const pixels = matchesPixelSize(size, bag) ? null : storablePixelSize(asset, size);
if (pixels) {
actions.push({
label: `Set to ${size.w} × ${size.h} px`,
onSelect: () => commit(pixels),
});
}
return actions;
},
},
];
+5 -2
View File
@@ -20,7 +20,7 @@ import {
} from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { aspectActions } from './aspectAction';
import { aspectActions, sizeCommand } from './aspectAction';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -101,7 +101,10 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
const target = findAsset(host.state.project, asset.id);
return target ? resolveAssetProps(target) : null;
},
(value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)),
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
),
),
reset: () => {
const target = findAsset(host.state.project, asset.id);
+1 -1
View File
@@ -56,7 +56,7 @@ export interface FieldRow {
const FIELD_LABEL_WIDTH = 64;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
interface FieldAction {
export interface FieldAction {
label: string;
onSelect: () => void;
}
+5 -2
View File
@@ -42,7 +42,7 @@ import {
} from '../../core/properties/resolve';
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions } from './aspectAction';
import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
@@ -379,7 +379,10 @@ function propertyRow(
owner,
def,
() => currentProps(host.state, holderId, objectId),
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
(values) =>
host.state.execute(
sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)),
),
),
reset: () => {
const { template, asset } = bases();
+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.