Place assets by dragging them from the panel into the view

The context menu entry is gone; dragging replaces it entirely. The drop
target is the view under the pointer, not the active tab, so the same
gesture works for scenes and for container templates.

While dragging, an outline shows the footprint the object will have plus
a crosshair on its origin, turning red when the drop would be refused.
Preview and command share one function (placementRejection), so the
outline cannot promise something the drop then rejects.

A left-press used to select the list row immediately. Since the container
tab follows the selection, it switched to the very container being dragged
and every drop would have nested it inside itself. The list now selects on
release without a drag: picking something up is not the same as choosing it.
This commit is contained in:
root committed 2026-09-09 13:09:13 +02:00
1 parent 14562cf157
commit 75d377af39
18 files changed
+545 -77

No files matched your search

+3 -5
View File
@@ -1,8 +1,7 @@
import { newId } from '../ids';
import { isPlaceableType } from '../model/AssetTypes';
import { containerReaches } from '../model/container';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
import { placementRejection } from '../model/placement';
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
@@ -37,9 +36,8 @@ export class PlaceAssetCommand implements Command {
execute(project: Project): void {
const holder = requireHolder(project, this.holderId);
const asset = requireAsset(project, this.assetId);
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
throw new CommandError(`${asset.name} cannot contain ${holder.name}: that nests forever`);
const rejection = placementRejection(project, holder, asset);
if (rejection) throw new CommandError(rejection);
if (this.parentId) {
requireObject(project, this.holderId, this.parentId);
requireStructural(project, holder, this.parentId);
+16
View File
@@ -0,0 +1,16 @@
import type { Asset } from './Asset';
import { isPlaceableType } from './AssetTypes';
import { containerReaches } from './container';
import type { ObjectHolder, Project } from './Project';
/**
* Why `asset` cannot be placed into `holder`, or '' when it can. A drag needs the answer while
* it hovers, `PlaceAssetCommand` needs it at the drop; both ask here so the preview cannot
* promise a placement the command then refuses.
*/
export function placementRejection(project: Project, holder: ObjectHolder, asset: Asset): string {
if (!isPlaceableType(asset.type)) return 'A scene cannot be placed into a scene';
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
return `${asset.name} cannot contain ${holder.name}: that nests forever`;
return '';
}
+3 -1
View File
@@ -42,6 +42,7 @@ export class Editor implements PlayHost {
readonly textureEditor: TextureEditor;
readonly containerEditor: ContainerEditor;
readonly hierarchy: HierarchyPanel;
readonly assets: AssetPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
fps: number | null = null;
@@ -91,7 +92,8 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1);
this.assets = new AssetPanel(state, () => this.placementTarget(), textures);
this.leftColumn.add(this.assets, 1);
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
+22 -4
View File
@@ -2,7 +2,18 @@ import { Graphics } from 'pixi.js';
import type { ContainerAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, listContainers } from '../../core/model/Project';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import {
activeDrag,
Button,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
Spacer,
Theme,
VStack,
} from '../../ui';
import type { EditorState } from '../state';
import { ContainerCanvas } from './ContainerCanvas';
@@ -63,7 +74,12 @@ export class ContainerEditor extends VStack {
this.refresh();
}
/** Middle of the view – where the Assets panel drops what is placed into this template. */
/** The pending drop of an asset dragged over the template (test hook). */
dropInfo(): ReturnType<ContainerCanvas['dropInfo']> {
return this.canvas.dropInfo();
}
/** Middle of the view – where a double-click in the Assets panel places into this template. */
worldCenter(): Vec2 {
return this.canvas.worldCenter();
}
@@ -81,8 +97,10 @@ export class ContainerEditor extends VStack {
private followSelection(): void {
const id = this.state.selectedContainerId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
// A dragged row selects on release, and whether that lands before or after the drop is up
// to Pixi's event order. Following it in between would swap the tab to the very container
// being dragged, which would then nest in itself, so the target stays put while a drag runs.
if (id && !activeDrag()) this.canvas.show(id);
this.refresh();
}
+6 -1
View File
@@ -10,7 +10,7 @@ import type { EditorState } from './state';
* visible text and to read editor state.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy, assets } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -139,6 +139,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
labelsDropped,
viewportCenter: () => centreOf(viewport),
viewportInfo: () => viewport.debugInfo(),
/** Where a dragged asset would land in the Scene tab, and why not when it may not. */
dropPreview: () => viewport.dropInfo(),
/** An asset row by id – two assets can share a name with the container picker's label. */
assetRow: (id: string) => assets.rowGlobal(id),
gameInfo: () => gameView.debugInfo(),
layerOf: (id: string) => viewport.layerOf(id),
drawOrder: () => viewport.drawOrder(),
@@ -171,6 +175,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
objectScreen: (id: string) => containerEditor.objectScreen(id),
dropPreview: () => containerEditor.dropInfo(),
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
+21 -9
View File
@@ -1,11 +1,17 @@
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import {
ASSET_TYPE_LABELS,
ASSET_TYPES,
type AssetType,
hasColorSwatch,
isPlaceableType,
} from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
import { pickAndImport } from '../textureImport';
@@ -47,6 +53,7 @@ export class AssetPanel extends Panel {
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
this.list.onActivate = (id) => this.activate(id);
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
this.list.onDragOut = (id) => this.startPlacementDrag(id);
this.scroll.setContent(this.list);
this.content.add(this.scroll, 1);
@@ -100,6 +107,18 @@ export class AssetPanel extends Panel {
else this.place(id);
}
/** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */
rowGlobal(assetId: string): { x: number; y: number } | null {
return this.list.rowGlobal(assetId);
}
/** Hands the asset to the viewport under the pointer; a scene has nowhere to be placed into. */
private startPlacementDrag(assetId: string): void {
const asset = findAsset(this.state.project, assetId);
if (!asset || !isPlaceableType(asset.type)) return;
startDrag({ kind: 'asset', id: assetId }, asset.name);
}
private place(assetId: string): void {
const target = this.target();
if (!target) {
@@ -121,13 +140,6 @@ export class AssetPanel extends Panel {
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
disabled: this.state.project.activeSceneId === id,
});
} else {
const target = this.target();
items.push({
label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
onSelect: () => this.place(id),
disabled: !target,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
const menu = new Menu(items);
+87
View File
@@ -0,0 +1,87 @@
import { Container, Graphics, Text } from 'pixi.js';
import { Theme, textStyle } from './Theme';
import { ui } from './UIContext';
/** What is being dragged. `kind` lets a drop target ignore payloads it does not take. */
export interface DragPayload {
kind: string;
id: string;
}
/** Offset of the ghost from the pointer, so the cursor keeps a clear view of the drop point. */
const GHOST_OFFSET = 14;
interface Session {
payload: DragPayload;
ghost: Container;
move: () => void;
stop: () => void;
}
let session: Session | null = null;
const listeners = new Set<() => void>();
/**
* A drag that left the widget it started in, e.g. an asset on its way to the viewport. The
* source starts it and forgets it; whoever is under the pointer reads `activeDrag()` and, on
* `pointerup`, acts. The session ends itself on release: it listens on the stage, which sees
* every pointer event last, so a drop target still has the payload when its own handler runs.
*/
export function startDrag(payload: DragPayload, label: string): void {
endDrag();
const { app, overlay } = ui();
const ghost = buildGhost(label);
overlay.addChild(ghost);
const move = (): void => {
const p = app.renderer.events.pointer.global;
ghost.position.set(p.x + GHOST_OFFSET, p.y + GHOST_OFFSET);
};
const stop = (): void => endDrag();
session = { payload, ghost, move, stop };
move();
app.stage.on('globalpointermove', move);
app.stage.on('pointerup', stop);
app.stage.on('pointerupoutside', stop);
notify();
}
export function activeDrag(): DragPayload | null {
return session?.payload ?? null;
}
export function endDrag(): void {
if (!session) return;
const { ghost, move, stop } = session;
session = null;
const { app } = ui();
app.stage.off('globalpointermove', move);
app.stage.off('pointerup', stop);
app.stage.off('pointerupoutside', stop);
ghost.destroy({ children: true });
notify();
}
/** Runs whenever a drag starts or ends – a hover preview uses it to clear itself. */
export function onDragChange(fn: () => void): void {
listeners.add(fn);
}
function notify(): void {
for (const fn of listeners) fn();
}
function buildGhost(label: string): Container {
const ghost = new Container();
ghost.eventMode = 'none';
ghost.alpha = 0.85;
const text = new Text({ text: label, style: textStyle('body') });
const box = new Graphics()
.roundRect(0, 0, text.width + 16, Theme.rowHeight, 4)
.fill(Theme.colors.panel)
.stroke({ width: 1, color: Theme.colors.accent });
text.position.set(8, (Theme.rowHeight - text.height) / 2);
ghost.addChild(box, text);
return ghost;
}
+1
View File
@@ -4,6 +4,7 @@ export { Theme, textStyle, type TextStyleToken } from './Theme';
export { initUI, ui, type UIContext } from './UIContext';
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
export { clamp, centreY, formatNumber, NUMBER_DECIMALS, snapToStep, zoomFactor } from './math';
export { activeDrag, type DragPayload, endDrag, onDragChange, startDrag } from './drag';
export { Label, truncate } from './widgets/Label';
export { Button, type ButtonOptions } from './widgets/Button';
export { Dropdown } from './widgets/Dropdown';
+30 -4
View File
@@ -63,6 +63,11 @@ export class ListView extends Widget {
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
/** Called when a dragged row is released somewhere that could take it. */
onDrop?: (draggedId: string, spot: DropSpot) => void;
/**
* Called once a press has travelled far enough to be a drag, for rows that are dragged out of
* the list rather than within it. The list then drops the gesture: the owner owns it from here.
*/
onDragOut?: (id: string) => void;
/**
* Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
* a tree: every row reserves the chevron slot, so labels stay aligned across a level.
@@ -136,6 +141,14 @@ export class ListView extends Widget {
return this.rows.find((r) => r.item.id === id)?.labelColor ?? null;
}
/** Global centre of a row; null when no such row is shown (test hook). */
rowGlobal(id: string): { x: number; y: number } | null {
const row = this.rows.find((r) => r.item.id === id);
if (!row) return null;
const p = row.getGlobalPosition();
return { x: p.x + this.w / 2, y: p.y + this.rowHeight / 2 };
}
/** Global centre of a row's chevron; null when the row has none. */
chevronGlobal(id: string): { x: number; y: number } | null {
return this.rows.find((r) => r.item.id === id)?.chevronGlobal() ?? null;
@@ -205,10 +218,13 @@ export class ListView extends Widget {
} else if (modifiers.ctrl || modifiers.shift) {
this.onSelect?.(item.id, modifiers);
} else {
// Clicking a highlighted row keeps the multi-selection so it can be dragged as a whole.
if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
// A row that leaves the list is selected on release instead (see `onDragEnd`): picking
// something up is not choosing it, and selecting an asset can move the very view it is
// being dragged into.
if (!this.onDragOut && (!this.highlighted.has(item.id) || item.id === this.selectedId))
this.select(item.id);
if (e.detail === 2) this.onActivate?.(item.id);
else if (this.onDrop) {
else if (this.onDrop || this.onDragOut) {
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null };
}
}
@@ -221,6 +237,12 @@ export class ListView extends Widget {
if (Math.hypot(e.global.x - drag.startX, e.global.y - drag.startY) < DRAG_THRESHOLD) return;
drag.active = true;
this.cursor = 'grabbing';
if (this.onDragOut) {
this.drag = null;
this.cursor = 'default';
this.onDragOut(drag.id);
return;
}
}
const local = this.toLocal(e.global);
drag.spot = this.spotAt(local.x, local.y, drag.id);
@@ -249,7 +271,11 @@ export class ListView extends Widget {
const drag = this.drag;
this.drag = null;
this.cursor = 'default';
if (!drag?.active) return;
if (!drag?.active) {
// The press never travelled, so it was a click after all – now it may select.
if (drag && this.onDragOut) this.select(drag.id);
return;
}
this.invalidate();
if (drag.spot) this.onDrop?.(drag.id, drag.spot);
}
+109 -3
View File
@@ -5,15 +5,26 @@
*/
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../core/commands/sceneObjectCommands';
import { isSymbolType } from '../core/model/AssetTypes';
import { findObject, subtreeRoots } from '../core/model/hierarchy';
import { objectRef, resolvedHolder } from '../core/model/instance';
import { placementRejection } from '../core/model/placement';
import type { Position, Vec2 } from '../core/model/Properties';
import type { ObjectHolder } from '../core/model/Project';
import { findAsset, type ObjectHolder } from '../core/model/Project';
import { footprintOf } from '../core/properties/bounds';
import { type EditorState, objectSelection } from '../editor/state';
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
import {
activeDrag,
clamp,
drawGrid,
endDrag,
onDragChange,
type Size,
Theme,
textStyle,
zoomFactor,
} from '../ui';
import { collectHolderItems, type DrawItem } from './items';
import { dashedLine } from './symbols';
import { freeView, screenToWorld, type View, worldToScreen } from './view';
@@ -37,6 +48,13 @@ interface ObjectDrag {
moved: boolean;
}
/** An asset hovering over the view on its way in: where it would land, and whether it may. */
interface DropPreview {
assetId: string;
at: Vec2;
rejection: string;
}
/** A rubber-band selection in progress, in viewport-local pixels. */
interface Marquee {
start: Vec2;
@@ -54,6 +72,7 @@ export class SceneViewport extends WorldViewport {
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
private dragging: ObjectDrag | null = null;
private marquee: Marquee | null = null;
private dropPreview: DropPreview | null = null;
constructor(state: EditorState) {
super(state);
@@ -73,6 +92,11 @@ export class SceneViewport extends WorldViewport {
this.view = { x: 0, y: 0, zoom: 1 };
this.redraw();
});
// A drag can end anywhere – over another panel, off the canvas – and this view never hears
// about it, so the preview would stay on screen without this.
onDragChange(() => {
if (!activeDrag()) this.setDropPreview(null);
});
}
/** What this view shows and edits; the Container tab returns its container asset instead. */
@@ -86,6 +110,12 @@ export class SceneViewport extends WorldViewport {
return holder && resolvedHolder(this.state.project, holder);
}
/** The pending drop: where a dragged asset would land and whether it may (test hook). */
dropInfo(): { assetId: string; x: number; y: number; rejection: string } | null {
const p = this.dropPreview;
return p ? { assetId: p.assetId, x: p.at.x, y: p.at.y, rejection: p.rejection } : null;
}
/** World coordinates at the centre of the view. */
worldCenter(): Vec2 {
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
@@ -166,6 +196,11 @@ export class SceneViewport extends WorldViewport {
}
private onPointerMove(e: FederatedPointerEvent): void {
const incoming = activeDrag();
if (incoming) {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
return;
}
if (this.panning) {
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
@@ -200,7 +235,52 @@ export class SceneViewport extends WorldViewport {
}
}
/**
* Follows an asset dragged in from the panel. `assetId` is empty for payloads this view does
* not take; the preview is dropped whenever the pointer leaves it, so nothing lands blind.
*/
private trackIncomingAsset(assetId: string, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const holder = this.holder();
const asset = assetId ? findAsset(this.state.project, assetId) : undefined;
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
if (!asset || !holder || !inside) {
this.setDropPreview(null);
return;
}
const world = screenToWorld(this.currentView(), local.x, local.y);
this.setDropPreview({
assetId,
at: { x: Math.round(world.x), y: Math.round(world.y) },
rejection: placementRejection(this.state.project, holder, asset),
});
}
private setDropPreview(preview: DropPreview | null): void {
if (!preview && !this.dropPreview) return;
this.dropPreview = preview;
this.redraw();
}
private dropAsset(preview: DropPreview): void {
const holder = this.holder();
if (!holder) return;
if (preview.rejection) {
this.state.setNotice(preview.rejection);
return;
}
const command = new PlaceAssetCommand(holder.id, preview.assetId, preview.at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private onPointerUp(): void {
const preview = this.dropPreview;
if (preview) {
this.setDropPreview(null);
endDrag();
this.dropAsset(preview);
return;
}
this.panning = null;
const marquee = this.marquee;
this.marquee = null;
@@ -352,6 +432,31 @@ export class SceneViewport extends WorldViewport {
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
}
/**
* Outline at the size the object will have – the footprint answers "does it fit here", which a
* cursor alone does not. Red means the drop would be refused; the crosshair is where it snaps.
*/
private drawDropPreview(view: View): void {
const preview = this.dropPreview;
const asset = preview && findAsset(this.state.project, preview.assetId);
if (!preview || !asset) return;
const color = preview.rejection ? Theme.colors.danger : Theme.colors.accent;
const { w, h } = footprintOf(asset.type, asset.props);
const centre = worldToScreen(view, preview.at.x, preview.at.y);
const sw = Math.max(w * view.scale, 8);
const sh = Math.max(h * view.scale, 8);
this.gizmos
.rect(centre.x - sw / 2, centre.y - sh / 2, sw, sh)
.fill({ color, alpha: 0.12 })
.stroke({ width: 2, color });
this.gizmos
.moveTo(centre.x - 6, centre.y)
.lineTo(centre.x + 6, centre.y)
.moveTo(centre.x, centre.y - 6)
.lineTo(centre.x, centre.y + 6)
.stroke({ width: 1, color });
}
/** Selection outlines – only the primary object gets corner handles – and the rubber band. */
private drawGizmos(items: DrawItem[], view: View): void {
const s = this.state.selection;
@@ -375,6 +480,7 @@ export class SceneViewport extends WorldViewport {
}
}
}
this.drawDropPreview(view);
const m = this.marquee;
if (m?.active) {
const x = Math.min(m.start.x, m.current.x);