diff --git a/README.md b/README.md index cbd646cb..d5011ec2 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 3100d13a..bbc096d8 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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); diff --git a/src/core/model/placement.ts b/src/core/model/placement.ts new file mode 100644 index 00000000..2406bf98 --- /dev/null +++ b/src/core/model/placement.ts @@ -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 ''; +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 070298ce..7d2872c3 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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); diff --git a/src/editor/container/ContainerEditor.ts b/src/editor/container/ContainerEditor.ts index 87cd88bc..d3c4e76c 100644 --- a/src/editor/container/ContainerEditor.ts +++ b/src/editor/container/ContainerEditor.ts @@ -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 { + 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(); } diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 6ce94f0b..a08b549e 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. */ diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index 5153dbfd..e9d226f6 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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); diff --git a/src/ui/drag.ts b/src/ui/drag.ts new file mode 100644 index 00000000..a16dd4a7 --- /dev/null +++ b/src/ui/drag.ts @@ -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; +} diff --git a/src/ui/index.ts b/src/ui/index.ts index 9a7e30ac..78dd86b8 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts index aeac9612..1fec7217 100644 --- a/src/ui/widgets/ListView.ts +++ b/src/ui/widgets/ListView.ts @@ -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); } diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts index 2f78eb21..091afa35 100644 --- a/src/viewport/SceneViewport.ts +++ b/src/viewport/SceneViewport.ts @@ -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); diff --git a/tests/core/placement.test.ts b/tests/core/placement.test.ts new file mode 100644 index 00000000..ab735635 --- /dev/null +++ b/tests/core/placement.test.ts @@ -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(''); + }); +}); diff --git a/todo-notes/asset-drag-and-drop.md b/todo-notes/asset-drag-and-drop.md deleted file mode 100644 index c53d39ed..00000000 --- a/todo-notes/asset-drag-and-drop.md +++ /dev/null @@ -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 diff --git a/todo-notes/done/asset-drag-and-drop.md b/todo-notes/done/asset-drag-and-drop.md new file mode 100644 index 00000000..b7308e4e --- /dev/null +++ b/todo-notes/done/asset-drag-and-drop.md @@ -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. diff --git a/tools/e2e/04-assets.mjs b/tools/e2e/04-assets.mjs index d93eaa52..cb61a120 100644 --- a/tools/e2e/04-assets.mjs +++ b/tools/e2e/04-assets.mjs @@ -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('+'); diff --git a/tools/e2e/09-containers.mjs b/tools/e2e/09-containers.mjs index 1f154a9c..a2c8d00a 100644 --- a/tools/e2e/09-containers.mjs +++ b/tools/e2e/09-containers.mjs @@ -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, ); diff --git a/tools/e2e/16-container.mjs b/tools/e2e/16-container.mjs index 74a54ae8..c19c9519 100644 --- a/tools/e2e/16-container.mjs +++ b/tools/e2e/16-container.mjs @@ -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); diff --git a/tools/lib/harness.mjs b/tools/lib/harness.mjs index 1b2d14e7..1aec0e8e 100644 --- a/tools/lib/harness.mjs +++ b/tools/lib/harness.mjs @@ -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,