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:
1 parent
14562cf157
commit
75d377af39
18 files changed
+545
-77
No files matched your search
@@ -112,6 +112,16 @@ shown on the node and editable in the inspector.
|
||||
- **Runtime**: `BehaviorSystem` creates one `GraphInterpreter` per behavior entity; graphs only
|
||||
touch world entities. A runaway loop throws after 1000 steps and stops play with a notice.
|
||||
|
||||
**Placing an asset: drag it in.** Drag a row out of the **Assets** panel and drop it on the
|
||||
view – the Scene tab places into the active scene, the Container tab into the open template, so
|
||||
what you see is what you fill. While the pointer is over a view, an outline shows the footprint
|
||||
the object will have and a crosshair the origin it snaps to; it turns **red** when the drop would
|
||||
be refused, which today means a container that would end up inside itself (`placementRejection`,
|
||||
`src/core/model/placement.ts` – the same function the command runs, so the preview cannot promise
|
||||
a placement the drop then denies). Picking a container up selects it, but the Container tab does
|
||||
*not* follow that while a drag is in flight: the target must not swap out from under the pointer.
|
||||
A double-click on the row still places at the middle of the visible view.
|
||||
|
||||
**Editor symbols are always on top.** The viewport draws scene content (sprites) in an
|
||||
`objects` layer and editor symbols (camera icon, labels) in an `overlay` layer above it, so a
|
||||
sprite can never hide a symbol regardless of z or hierarchy order. A viewport label keeps a
|
||||
@@ -290,11 +300,9 @@ a scene asset and a container asset are both `ObjectHolder`s, and every object c
|
||||
`holderId` that is either one.
|
||||
|
||||
**Header bar**: the container picker (searchable), **Fit** (frames the content, origin always
|
||||
included) and the size of the template as "N objects". Placing needs no button of its own: the
|
||||
Assets panel already places, and its entry follows the visible tab – **Place in scene** becomes
|
||||
**Place in container** while this tab is open, and drops at the middle of *this* view. A right-click
|
||||
marks a row without selecting it, so opening that menu on a container does not load it into the tab
|
||||
first – otherwise nesting one container in another would place it into itself.
|
||||
included) and the size of the template as "N objects". Placing needs no button of its own: an
|
||||
asset dragged from the panel onto this view goes into the template, and a container dragged onto
|
||||
its own template is refused while it hovers rather than at the drop.
|
||||
Like the Graph tab it follows the selection without being bound to it – the container asset, one of
|
||||
its placed copies or one of its template objects shows it, anything else leaves the tab on what you
|
||||
were editing, and only a deleted container makes it fall back to another one. Selecting a template
|
||||
@@ -416,7 +424,7 @@ npm run format # Prettier over src, tests and tools
|
||||
npm run check # typecheck + lint + format:check + tests, what CI would run
|
||||
npm run build # writes dist/ and regenerates index.html
|
||||
export GM_REMOTE=user@host # where the browser runs; see below
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 287 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # 293 editor checks, screenshots in /tmp/gm-e2e-*.png
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
|
||||
```
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||||
import { placementRejection } from '../../src/core/model/placement';
|
||||
import { findAsset, findObjectHolder, findScene } from '../../src/core/model/Project';
|
||||
import { makeSpriteScene, place } from '../helpers';
|
||||
|
||||
describe('placementRejection', () => {
|
||||
it('lets a sprite into a scene', () => {
|
||||
const f = makeSpriteScene();
|
||||
const scene = findScene(f.project, f.sceneId)!;
|
||||
const sprite = findAsset(f.project, f.spriteId)!;
|
||||
expect(placementRejection(f.project, scene, sprite)).toBe('');
|
||||
});
|
||||
|
||||
it('refuses a scene: a scene is not something a scene can hold', () => {
|
||||
const f = makeSpriteScene();
|
||||
const scene = findScene(f.project, f.sceneId)!;
|
||||
expect(placementRejection(f.project, scene, scene)).toMatch(/cannot be placed/);
|
||||
});
|
||||
|
||||
it('refuses a container that would end up inside itself', () => {
|
||||
const f = makeSpriteScene();
|
||||
const a = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const create = new CreateContainerCommand(f.sceneId, [a]);
|
||||
f.stack.execute(create);
|
||||
const container = findAsset(f.project, create.assetId) as ContainerAsset;
|
||||
const template = findObjectHolder(f.project, create.assetId)!;
|
||||
|
||||
expect(placementRejection(f.project, template, container)).toMatch(/nests forever/);
|
||||
// The same container in a scene is fine – only its own template nests.
|
||||
expect(placementRejection(f.project, findScene(f.project, f.sceneId)!, container)).toBe('');
|
||||
});
|
||||
|
||||
it('refuses a container whose template already reaches the target, however deeply', () => {
|
||||
const f = makeSpriteScene();
|
||||
const inner = new CreateContainerCommand(f.sceneId, [place(f, f.spriteId, { x: 0, y: 0 })]);
|
||||
f.stack.execute(inner);
|
||||
// An outer container built from the object that holds the inner one.
|
||||
const outer = new CreateContainerCommand(f.sceneId, [inner.objectId]);
|
||||
f.stack.execute(outer);
|
||||
const outerAsset = findAsset(f.project, outer.assetId) as ContainerAsset;
|
||||
const innerTemplate = findObjectHolder(f.project, inner.assetId)!;
|
||||
|
||||
expect(placementRejection(f.project, innerTemplate, outerAsset)).toMatch(/nests forever/);
|
||||
});
|
||||
|
||||
it('does not care what an asset is called: a fresh sprite is placeable', () => {
|
||||
const f = makeSpriteScene();
|
||||
const created = new CreateAssetCommand('text', 'Label');
|
||||
f.stack.execute(created);
|
||||
const scene = findScene(f.project, f.sceneId)!;
|
||||
expect(placementRejection(f.project, scene, created.asset)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
momentan funktioniert das platzieren eines asset in der scene/container per "Place in ..." über das kontextmenü.
|
||||
zukünftig soll es per drag and drop passieren
|
||||
@@ -0,0 +1,29 @@
|
||||
momentan funktioniert das platzieren eines asset in der scene/container per "Place in ..." über das kontextmenü.
|
||||
zukünftig soll es per drag and drop passieren
|
||||
|
||||
---
|
||||
|
||||
**Umgesetzt (2026-09-09).** Drag and Drop ersetzt den Menueintrag vollstaendig — auf deine
|
||||
Ansage hin ist "Place in scene"/"Place in container" aus dem Kontextmenue verschwunden.
|
||||
Doppelklick auf die Zeile platziert weiterhin in die Mitte der sichtbaren Ansicht.
|
||||
|
||||
- **Ziehen:** die Zeile im Assets-Panel wird nach 4 px Weg zum Drag; ein Ghost mit dem
|
||||
Asset-Namen haengt am Cursor (`src/ui/drag.ts`). Die `ListView` erkennt die Geste nur und
|
||||
gibt sie per `onDragOut` nach aussen ab — sie kennt kein Ziel ausserhalb ihrer selbst.
|
||||
- **Vorschau:** ueber einer Ansicht zeigt ein Umriss die **Groesse**, die das Objekt haben wird,
|
||||
plus Fadenkreuz auf dem Ursprung. Rot = der Drop wird abgelehnt.
|
||||
- **Ziel:** die Ansicht unter dem Zeiger, nicht der aktive Tab. Scene-Tab → aktive Szene,
|
||||
Container-Tab → offene Vorlage. `ContainerCanvas` erbt das von `SceneViewport`.
|
||||
- **Regel:** `placementRejection` (`src/core/model/placement.ts`) — dieselbe Funktion, die
|
||||
`PlaceAssetCommand` ausfuehrt. Die Vorschau kann also nichts versprechen, was der Drop
|
||||
danach verweigert. Heute lehnt sie ab: Szenen und Container, die in sich selbst landen.
|
||||
|
||||
**Zwei Fallen, die beim Bauen erst sichtbar wurden:**
|
||||
|
||||
1. Ein Drag beginnt mit einem **Linksklick**, und der selektiert die Zeile — der Container-Tab
|
||||
folgte der Selektion und haette sich mitten im Drag auf genau den Container umgeschaltet,
|
||||
der gerade gezogen wird. Das Ziel waere unter dem Zeiger weggerutscht und jeder Drop haette
|
||||
sich selbst verschachtelt. `followSelection` haelt jetzt still, solange ein Drag laeuft.
|
||||
Vorher schuetzte davor der Rechtsklick, der nicht selektiert; mit Drag&Drop trug das nicht mehr.
|
||||
2. Ein Drag endet oft dort, wo die Zielansicht **nichts davon mitbekommt** (ueber einem anderen
|
||||
Panel, ausserhalb des Canvas). Ohne `onDragChange` waere die Vorschau stehengeblieben.
|
||||
+67
-7
@@ -1,15 +1,75 @@
|
||||
/** The asset panel: placing from the context menu, creating through "+", and resetting an asset value. */
|
||||
/** The asset panel: placing by dragging into the view, creating through "+", and resetting an asset value. */
|
||||
export async function assets(t) {
|
||||
const { check, sleep, evaluate, json, shot, click, clickText, undo, select, objects, asset } = t;
|
||||
const {
|
||||
check,
|
||||
sleep,
|
||||
evaluate,
|
||||
json,
|
||||
shot,
|
||||
click,
|
||||
clickText,
|
||||
mouse,
|
||||
press,
|
||||
redo,
|
||||
undo,
|
||||
select,
|
||||
objects,
|
||||
asset,
|
||||
} = t;
|
||||
|
||||
// 8. Context menu on the Crate asset → menu shows "Place in scene".
|
||||
// 8. Context menu on the Crate asset. Placing left the menu when it became a drag.
|
||||
await clickText('Crate', 'right');
|
||||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Place in scene')`)));
|
||||
check('asset context menu opens', Boolean(await evaluate(`__gm.locate('Delete…')`)));
|
||||
check('placing is no longer a menu item', !(await evaluate(`__gm.locate('Place in scene')`)));
|
||||
await shot('11-context-menu');
|
||||
await clickText('Place in scene');
|
||||
await sleep(100);
|
||||
await press('Escape');
|
||||
await sleep(150);
|
||||
|
||||
// 8b. Drag the Crate asset from the panel into the scene view. Driven step by step rather than
|
||||
// with `drag()`, because the preview only exists while the button is still down.
|
||||
const from = await json("__gm.assetRow('crate')");
|
||||
const centre = await json('__gm.viewportCenter()');
|
||||
const to = { x: centre.x + 70, y: centre.y - 50 };
|
||||
await mouse('mouseMoved', from.x, from.y);
|
||||
await mouse('mousePressed', from.x, from.y);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
const x = from.x + ((to.x - from.x) * i) / 8;
|
||||
const y = from.y + ((to.y - from.y) * i) / 8;
|
||||
await mouse('mouseMoved', x, y, 'left', { buttons: 1 });
|
||||
await sleep(16);
|
||||
}
|
||||
const preview = await json('__gm.dropPreview()');
|
||||
check(
|
||||
'dragging an asset over the view previews where it would land',
|
||||
Boolean(preview) && preview.assetId === 'crate' && preview.rejection === '',
|
||||
JSON.stringify(preview),
|
||||
);
|
||||
await shot('11b-drop-preview');
|
||||
await mouse('mouseReleased', to.x, to.y);
|
||||
await sleep(200);
|
||||
|
||||
const count = (await objects()).length;
|
||||
check('placing from the menu adds an object', count === 8, `objects=${count}`);
|
||||
check('dropping the asset adds an object', count === 8, `objects=${count}`);
|
||||
const placed = await json(
|
||||
'(() => { const id = __gm.state.selectedObjectIds[0]; return __gm.sceneObjects().find(o => o.id === id) ?? null; })()',
|
||||
);
|
||||
// The preview is a promise: whatever it drew has to be where the object then sits, or the
|
||||
// outline was decoration rather than a prediction.
|
||||
check(
|
||||
'the object lands where the preview promised, and is selected',
|
||||
Boolean(placed) &&
|
||||
placed.assetId === 'crate' &&
|
||||
placed.position.x === preview.x &&
|
||||
placed.position.y === preview.y,
|
||||
JSON.stringify({ placed: placed && placed.position, preview }),
|
||||
);
|
||||
check('the preview is gone once the asset is placed', (await json('__gm.dropPreview()')) === null);
|
||||
await undo();
|
||||
const afterUndo = (await objects()).length;
|
||||
check('one undo takes the dropped object back out', afterUndo === 7, `objects=${afterUndo}`);
|
||||
await redo();
|
||||
await sleep(150);
|
||||
check('redo puts it back', (await objects()).length === 8);
|
||||
|
||||
// 9. Create a new asset through the + menu.
|
||||
await clickText('+');
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function containers(t) {
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
dragAsset,
|
||||
locate,
|
||||
press,
|
||||
typeText,
|
||||
@@ -220,9 +221,8 @@ export async function containers(t) {
|
||||
|
||||
// 16f. Placing the container asset again adds exactly ONE stored object – and five more that
|
||||
// the editor lists. Against the old copying model the stored count would jump by six.
|
||||
await clickText(containerAsset.name, 'right');
|
||||
await clickText('Place in scene');
|
||||
await sleep(150);
|
||||
const spot = await json('__gm.viewportCenter()');
|
||||
await dragAsset(containerAsset.id, spot.x + 90, spot.y + 60);
|
||||
const instanceB = (await storedObjects()).find(
|
||||
(o) => o.assetId === containerAsset.id && o.id !== instanceA.id,
|
||||
);
|
||||
@@ -353,9 +353,7 @@ export async function containers(t) {
|
||||
|
||||
// 16j. Unpack: the content is written into the scene as ordinary objects under the very same
|
||||
// ids, the instance stops following the template – and now its structure *can* be changed.
|
||||
await clickText(containerAsset.name, 'right');
|
||||
await clickText('Place in scene');
|
||||
await sleep(150);
|
||||
await dragAsset(containerAsset.id, spot.x - 90, spot.y + 60);
|
||||
const instanceC = (await storedObjects()).find(
|
||||
(o) => o.assetId === containerAsset.id && o.id !== instanceA.id && o.id !== instanceB.id,
|
||||
);
|
||||
|
||||
+49
-29
@@ -23,10 +23,11 @@ export async function containerTab(t) {
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
dragAsset,
|
||||
mouse,
|
||||
press,
|
||||
undo,
|
||||
state,
|
||||
asset,
|
||||
object,
|
||||
objects,
|
||||
storedObjects,
|
||||
@@ -135,22 +136,15 @@ export async function containerTab(t) {
|
||||
);
|
||||
await setDeviation(`delete edits['${root.id}']`);
|
||||
|
||||
// 22d. Placing goes through the Assets panel, whose entry follows the visible tab: it adds one
|
||||
// object to the template, not to the scene.
|
||||
const crateName = (await asset('crate')).name;
|
||||
// 22d. An asset dragged from the panel lands in the view it is dropped on: dropping on this
|
||||
// tab adds one object to the template, not to the scene.
|
||||
// What the scene *stores* – that must not move. What it *lists* has to grow by one per placed
|
||||
// instance instead, since every one of them resolves the new template object too.
|
||||
const sceneStored = (await storedObjects()).length;
|
||||
const sceneListed = (await objects()).length;
|
||||
const instanceCount = Object.keys(await instanceXs()).length;
|
||||
await clickText(crateName, 'right');
|
||||
check(
|
||||
'the asset menu offers Place in container while the tab is open',
|
||||
Boolean(await evaluate("__gm.locate('Place in container')")) &&
|
||||
!(await evaluate("__gm.locate('Place in scene')")),
|
||||
);
|
||||
await clickText('Place in container');
|
||||
await sleep(300);
|
||||
const canvas = await json('__gm.container.center()');
|
||||
await dragAsset('crate', canvas.x + 40, canvas.y + 40);
|
||||
const grown = await json(`__gm.state.project.assets.find(a => a.id === '${containerId}').objects`);
|
||||
s = await state();
|
||||
check(
|
||||
@@ -230,13 +224,6 @@ export async function containerTab(t) {
|
||||
(await json('__gm.hierarchyOrder()')).includes('otex'),
|
||||
JSON.stringify(await json('__gm.hierarchyOrder()')),
|
||||
);
|
||||
await clickText(crateName, 'right');
|
||||
check(
|
||||
'and back on the Scene tab the same entry reads Place in scene',
|
||||
Boolean(await evaluate("__gm.locate('Place in scene')")) &&
|
||||
!(await evaluate("__gm.locate('Place in container')")),
|
||||
);
|
||||
await press('Escape');
|
||||
|
||||
// 22h. Switching to another container is a plain asset selection – the same thing the header's
|
||||
// picker does. The hierarchy has to follow it, title and rows both.
|
||||
@@ -261,17 +248,51 @@ export async function containerTab(t) {
|
||||
);
|
||||
await shot('33-container-switched');
|
||||
|
||||
// 22i. Nesting: a right-click must not select, or picking the container to place would load it
|
||||
// into the tab first – you would then be placing it into itself.
|
||||
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
await clickText(NAME, 'right');
|
||||
// 22i2. Dragging the open container onto its own template would nest it in itself. The drag
|
||||
// has to say so while it hovers – an outline that only turns out to be a lie on release
|
||||
// is worse than none – and the drop has to change nothing.
|
||||
const canvas2 = await json('__gm.container.center()');
|
||||
const own = await json("__gm.assetRow('c2')");
|
||||
await mouse('mouseMoved', own.x, own.y);
|
||||
await mouse('mousePressed', own.x, own.y);
|
||||
for (let i = 1; i <= 8; i++) {
|
||||
await mouse(
|
||||
'mouseMoved',
|
||||
own.x + ((canvas2.x - own.x) * i) / 8,
|
||||
own.y + ((canvas2.y - own.y) * i) / 8,
|
||||
'left',
|
||||
{
|
||||
buttons: 1,
|
||||
},
|
||||
);
|
||||
await sleep(16);
|
||||
}
|
||||
const refused = await json('__gm.container.dropPreview()');
|
||||
check(
|
||||
'right-clicking another container leaves the open one alone',
|
||||
'a container dragged over its own template is refused while it hovers',
|
||||
Boolean(refused) && /nests forever/.test(refused.rejection ?? ''),
|
||||
JSON.stringify(refused),
|
||||
);
|
||||
// Picking it up selects it; the tab must not follow that, or the target would swap mid-drag.
|
||||
check(
|
||||
'and the tab keeps showing the container being dropped into',
|
||||
(await json('__gm.container.current()')) === 'c2',
|
||||
JSON.stringify({ shown: await json('__gm.container.current()') }),
|
||||
);
|
||||
await clickText('Place in container');
|
||||
await sleep(300);
|
||||
await shot('33b-container-cycle-refused');
|
||||
const ownBefore = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
await mouse('mouseReleased', canvas2.x, canvas2.y);
|
||||
await sleep(200);
|
||||
const ownAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
check(
|
||||
'releasing it changes nothing',
|
||||
ownAfter.length === ownBefore.length,
|
||||
JSON.stringify({ before: ownBefore.length, after: ownAfter.length }),
|
||||
);
|
||||
|
||||
// 22i. Nesting: another container, though, may go in – and the tab it lands in stays put.
|
||||
const nested = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
await dragAsset(containerId, canvas2.x - 40, canvas2.y + 40);
|
||||
const nestedAfter = await json("__gm.state.project.assets.find(a => a.id === 'c2').objects");
|
||||
check(
|
||||
'and placing it nests it inside that one',
|
||||
@@ -289,9 +310,8 @@ export async function containerTab(t) {
|
||||
await sleep(300);
|
||||
const storedBefore = (await storedObjects()).length;
|
||||
const listedBefore = (await objects()).length;
|
||||
await clickText('Spare box', 'right');
|
||||
await clickText('Place in scene');
|
||||
await sleep(300);
|
||||
const spot = await json('__gm.viewportCenter()');
|
||||
await dragAsset('c2', spot.x + 80, spot.y + 70);
|
||||
const outer = (await storedObjects()).find((o) => o.assetId === 'c2');
|
||||
const deep = innerTemplate.map((o) => instanceChild(outer.id, innerObject.id, o.id));
|
||||
const listedIds = (await objects()).map((o) => o.id);
|
||||
|
||||
@@ -174,6 +174,33 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
await sleep(150);
|
||||
};
|
||||
|
||||
/**
|
||||
* Drags an asset out of the Assets panel onto a point in a view – the only way to place one
|
||||
* since the context menu lost its entry. By id, not by text: the container picker shows the
|
||||
* same name as the row. Unlike `drag()` it presses and moves in separate events on purpose,
|
||||
* because the panel turns the press into a drag only once it has travelled.
|
||||
*/
|
||||
const dragAsset = async (assetId, x, y, steps = 8) => {
|
||||
const from = await json(`__gm.assetRow(${JSON.stringify(assetId)})`);
|
||||
if (!from) throw new Error(`no asset row for "${assetId}" – is the panel scrolled past it?`);
|
||||
await mouse('mouseMoved', from.x, from.y);
|
||||
await mouse('mousePressed', from.x, from.y);
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await mouse(
|
||||
'mouseMoved',
|
||||
from.x + ((x - from.x) * i) / steps,
|
||||
from.y + ((y - from.y) * i) / steps,
|
||||
'left',
|
||||
{
|
||||
buttons: 1,
|
||||
},
|
||||
);
|
||||
await sleep(16);
|
||||
}
|
||||
await mouse('mouseReleased', x, y);
|
||||
await sleep(200);
|
||||
};
|
||||
|
||||
// --- keyboard ------------------------------------------------------------------------------
|
||||
|
||||
const keyEvent = (type, name, modifiers) => {
|
||||
@@ -291,6 +318,7 @@ function makeTools({ cdp, projectId, baseUrl, consoleLog, check, shotPrefix }) {
|
||||
click,
|
||||
clickText,
|
||||
drag,
|
||||
dragAsset,
|
||||
press,
|
||||
holdKey,
|
||||
typeText,
|
||||
|
||||
Reference in new issue
Block a user