diff --git a/README.md b/README.md index 5b9a4df5..4d3d3240 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1423 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1432 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/pasteCommands.ts b/src/core/commands/pasteCommands.ts new file mode 100644 index 00000000..18d56c88 --- /dev/null +++ b/src/core/commands/pasteCommands.ts @@ -0,0 +1,164 @@ +import { annotationList, type Graph } from '../graph/Graph'; +import { isTerminalType, terminalNode } from '../graph/nested'; +import { nodeDef } from '../graph/NodeRegistry'; +import { newId } from '../ids'; +import type { GraphClip, ObjectClip } from '../model/clipboard'; +import { isLiveInstance } from '../model/instance'; +import { placementRejection } from '../model/placement'; +import type { Vec2 } from '../model/Properties'; +import { findAsset, type Project } from '../model/Project'; +import type { SceneObject } from '../model/SceneObject'; +import { carryTransform, localPositionFor, parentTransform } from '../properties/transform'; +import { type Command, CommandError } from './Command'; +import { requireGraph, requireHolder, requireNodeDef, requireStructuralObject } from './require'; + +/** Fresh ids for the originals', made once so a redo puts back the very same objects. */ +function freshIds(ids: readonly string[]): Map { + return new Map(ids.map((id) => [id, newId()])); +} + +/** + * Puts copied objects into a scene or container (#240). Each root goes under the parent `parents` + * names for it ('' or missing = top level), shifted by `shift` in world units, and keeps its look + * the way a re-parent does. + */ +export class PasteObjectsCommand implements Command { + readonly label: string; + private readonly ids: Map; + + constructor( + private readonly holderId: string, + private readonly clip: ObjectClip, + private readonly parents: Readonly>, + private readonly shift: Vec2, + ) { + this.ids = freshIds(clip.objects.map((o) => o.id)); + this.label = clip.roots.length === 1 ? 'Paste object' : 'Paste objects'; + } + + /** The new ids of the pasted roots, in the order they were copied. */ + get rootIds(): string[] { + return this.clip.roots.map((root) => this.ids.get(root.id)!); + } + + execute(project: Project): void { + const holder = requireHolder(project, this.holderId); + for (const object of this.clip.objects) { + const asset = findAsset(project, object.assetId); + if (!asset) throw new CommandError('A copied object belongs to an asset that no longer exists'); + const rejection = placementRejection(project, holder, asset); + if (rejection) throw new CommandError(rejection); + } + for (const parentId of new Set(Object.values(this.parents))) + if (parentId && isLiveInstance(project, holder, requireStructuralObject(project, holder, parentId))) + throw new CommandError('A placed container shows its template; unpack it to put objects inside'); + const roots = new Map(this.clip.roots.map((root) => [root.id, root])); + const pasted = this.clip.objects.map((object): SceneObject => { + const copy = structuredClone(object); + copy.id = this.ids.get(object.id)!; + const root = roots.get(object.id); + if (!root) { + copy.parentId = this.ids.get(object.parentId) ?? ''; + return copy; + } + const parentId = this.parents[object.id] ?? ''; + copy.parentId = parentId; + copy.position = localPositionFor(project, holder, parentId, { + x: root.world.x + this.shift.x, + y: root.world.y + this.shift.y, + z: root.world.z, + }); + const after = parentTransform(project, holder, parentId).m; + carryTransform(findAsset(project, object.assetId)!, copy, root.parentMatrix, after); + return copy; + }); + holder.objects.push(...pasted); + } + + undo(project: Project): void { + const holder = requireHolder(project, this.holderId); + const ids = new Set(this.ids.values()); + holder.objects = holder.objects.filter((o) => !ids.has(o.id)); + } +} + +/** + * Puts copied nodes, their links among each other and the copied boxes into a graph, shifted by + * `shift`. Which nodes a graph may take at all is the palette's question, asked before this runs. + */ +export class PasteGraphCommand implements Command { + readonly label: string; + private readonly ids: Map; + + constructor( + private readonly assetId: string, + private readonly clip: GraphClip, + private readonly shift: Vec2, + ) { + this.ids = freshIds([ + ...clip.nodes.map((n) => n.id), + ...clip.edges.map((e) => e.id), + ...clip.annotations.map((a) => a.annotation.id), + ]); + const count = clip.nodes.length + clip.annotations.length; + this.label = count === 1 ? 'Paste' : `Paste ${count} items`; + } + + get nodeIds(): string[] { + return this.clip.nodes.map((n) => this.ids.get(n.id)!); + } + + get annotationIds(): string[] { + return this.clip.annotations.map((a) => this.ids.get(a.annotation.id)!); + } + + execute(project: Project): void { + const graph = requireGraph(project, this.assetId); + for (const node of this.clip.nodes) { + requireNodeDef(node.type); + if (isTerminalType(node.type) && terminalNode(graph, node.type)) + throw new CommandError(`This graph already has a ${nodeDef(node.type)?.label} node`); + } + const dx = Math.round(this.shift.x); + const dy = Math.round(this.shift.y); + for (const node of this.clip.nodes) + graph.nodes.push({ + ...structuredClone(node), + id: this.ids.get(node.id)!, + x: node.x + dx, + y: node.y + dy, + }); + for (const edge of this.clip.edges) { + const from = this.ids.get(edge.from.node); + const to = this.ids.get(edge.to.node); + // A node the target could not take is missing from the clip, and its links go with it. + if (!from || !to) continue; + graph.edges.push({ + ...structuredClone(edge), + id: this.ids.get(edge.id)!, + from: { node: from, port: edge.from.port }, + to: { node: to, port: edge.to.port }, + }); + } + for (const { kind, annotation } of this.clip.annotations) + annotationList(graph, kind).push({ + ...annotation, + id: this.ids.get(annotation.id)!, + x: annotation.x + dx, + y: annotation.y + dy, + }); + } + + undo(project: Project): void { + const graph = requireGraph(project, this.assetId); + const ids = new Set(this.ids.values()); + graph.nodes = graph.nodes.filter((n) => !ids.has(n.id)); + graph.edges = graph.edges.filter((e) => !ids.has(e.id)); + dropFrom(graph, ids); + } +} + +function dropFrom(graph: Graph, ids: ReadonlySet): void { + if (graph.groups) graph.groups = graph.groups.filter((a) => !ids.has(a.id)); + if (graph.notes) graph.notes = graph.notes.filter((a) => !ids.has(a.id)); +} diff --git a/src/core/model/clipboard.ts b/src/core/model/clipboard.ts new file mode 100644 index 00000000..912c3d45 --- /dev/null +++ b/src/core/model/clipboard.ts @@ -0,0 +1,108 @@ +/** + * What Ctrl+C takes (#240): a snapshot of placed objects or of a piece of graph, detached from the + * model so later edits to the originals do not change what is pasted. Ids are the originals'; the + * paste commands hand out fresh ones. + */ +import { + type AnnotationKind, + findAnnotation, + type Graph, + type GraphAnnotation, + type GraphEdge, + type GraphNode, +} from '../graph/Graph'; +import { parentTransform, positionOf, worldTransform } from '../properties/transform'; +import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy'; +import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance'; +import type { Mat } from './matrix'; +import type { Position } from './Properties'; +import type { ObjectHolder, Project } from './Project'; +import type { SceneObject } from './SceneObject'; + +/** A copied subtree's top: where it stood in the world and the frame its own rows were meant in. */ +export interface ClipRoot { + id: string; + world: Position; + parentMatrix: Mat; +} + +export interface ObjectClip { + kind: 'objects'; + /** Parents before children; a root's `parentId` is the one it had where it was copied. */ + objects: SceneObject[]; + roots: ClipRoot[]; +} + +export interface GraphClip { + kind: 'graph'; + nodes: GraphNode[]; + /** Only the edges between copied nodes – a link to a node left behind has nowhere to go. */ + edges: GraphEdge[]; + annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[]; +} + +export type Clip = ObjectClip | GraphClip; + +/** + * The selected objects with everything below them. A placed container keeps showing its template, + * so its resolved content stays behind; content picked out of a placed container is copied as the + * ordinary objects it is shown as. + */ +export function copyObjects( + project: Project, + holder: ObjectHolder, + ids: readonly string[], +): ObjectClip | null { + const view = resolvedHolder(project, holder); + const roots = subtreeRoots( + view, + ids.filter((id) => findObject(view, id)), + ); + if (roots.length === 0) return null; + const members = subtreeIds(view, roots); + const instances = new Set( + view.objects + .filter( + (o) => members.has(o.id) && !isTemplateContent(holder, o.id) && isLiveInstance(project, holder, o), + ) + .map((o) => o.id), + ); + const shownByInstance = (id: string) => + isTemplateContent(holder, id) && instances.has(splitInstanceId(id)?.instanceId ?? ''); + const objects = treeOrder(view) + .map((entry) => entry.object) + .filter((o) => members.has(o.id) && !shownByInstance(o.id)) + .map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } })); + return { + kind: 'objects', + objects, + roots: roots.map((id) => { + const object = findObject(view, id)!; + return { + id, + world: positionOf(worldTransform(project, view, object)), + parentMatrix: parentTransform(project, view, object.parentId).m, + }; + }), + }; +} + +export function copyGraph( + graph: Graph, + nodeIds: readonly string[], + annotationIds: readonly string[], +): GraphClip | null { + const ids = new Set(nodeIds); + const nodes = graph.nodes.filter((n) => ids.has(n.id)); + const annotations = annotationIds.flatMap((id) => { + const found = findAnnotation(graph, id); + return found ? [found] : []; + }); + if (nodes.length === 0 && annotations.length === 0) return null; + return structuredClone({ + kind: 'graph', + nodes, + edges: graph.edges.filter((e) => ids.has(e.from.node) && ids.has(e.to.node)), + annotations, + }); +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index fdf268ae..fbbffb43 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -66,6 +66,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor'; import { installShortcuts } from './shortcuts'; import type { PlayFrom } from '../core/model/playSettings'; import type { EditorState } from './state'; +import { copySelection } from './clipboard'; import { PixelArtEditor } from './pixelart/PixelArtEditor'; import { VectorArtEditor } from './vectorart/VectorArtEditor'; import { TextureEditor } from './texture/TextureEditor'; @@ -436,8 +437,9 @@ export class Editor implements PlayHost { play: () => this.play.play(), stop: () => this.play.stop(), togglePause: () => this.play.togglePause(), - copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(), - pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(), + copy: () => + (this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state), + paste: () => this.paste(), }), ); const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); @@ -477,6 +479,28 @@ export class Editor implements PlayHost { state.on('mode', () => this.editorRun.invalidate()); } + /** Ctrl+V lands in whatever the visible tab edits (#240). */ + private paste(): boolean { + switch (this.tab) { + case 'animation': + return this.animationEditor.canvas.pasteKeys(); + case 'scene': + return this.viewport.pasteHere(); + case 'container': + return this.containerEditor.canvas.pasteHere(); + case 'graph': + return this.graphEditor.canvas.paste(); + case 'statemachine': + return this.stateMachineEditor.canvas.paste(); + case 'shader': + return this.shaderEditor.canvas.paste(); + case 'particles': + return this.particlesEditor.canvas.paste(); + default: + return false; + } + } + /** * The main menu's last group (#179): the user management for an administrator, and the plugins * for everybody – both open the one tab, which holds the plugins below the accounts. diff --git a/src/editor/clipboard.ts b/src/editor/clipboard.ts new file mode 100644 index 00000000..e8bde947 --- /dev/null +++ b/src/editor/clipboard.ts @@ -0,0 +1,240 @@ +/** + * Copy and paste of placed objects and graph pieces (#240). The clipboard is the editor's own: a + * copy names assets of this project, which mean nothing anywhere else. + */ +import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands'; +import { + BEHAVIOR_PARAM, + findAnnotation, + findNode, + type GraphNode, + nodeDef, + RUN_BEHAVIOR, +} from '../core/graph'; +import type { GraphAsset } from '../core/model/Asset'; +import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard'; +import { findObject } from '../core/model/hierarchy'; +import { isLiveInstance } from '../core/model/instance'; +import type { Vec2 } from '../core/model/Properties'; +import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project'; +import { GRID_STEP } from './graph/graphView'; +import { paletteEntries } from './graph/paletteEntries'; +import { type EditorState, graphSelection, objectSelection } from './state'; +import { SCENE_GRID_STEP } from './viewport/sceneViewHost'; + +/** + * `lineage` holds the originals and everything pasted from them since: selecting one of those and + * pasting puts the copy beside it, not inside it. `pastes` holds one id per paste; those still there + * stagger the next paste that lands without a pointer, so an undone paste frees its spot again. + */ +export interface EditorClipboard { + clip: Clip; + lineage: Set; + pastes: string[]; +} + +function put(state: EditorState, clip: Clip, lineage: Iterable, noun: string): true { + state.clipboard = { clip, lineage: new Set(lineage), pastes: [] }; + state.setNotice(`Copied ${noun}`); + return true; +} + +function counted(count: number, one: string): string { + return count === 1 ? `1 ${one}` : `${count} ${one}s`; +} + +/** Copies the objects with everything below them; false when none of them is there. */ +export function copyObjectIds(state: EditorState, holderId: string, ids: readonly string[]): boolean { + const holder = findObjectHolder(state.project, holderId); + const clip = holder && copyObjects(state.project, holder, ids); + if (!clip) return false; + const roots = clip.roots.map((r) => r.id); + return put(state, clip, roots, counted(roots.length, 'object')); +} + +/** Copies the selected objects, or the selected nodes and boxes; false when nothing of that is selected. */ +export function copySelection(state: EditorState): boolean { + const s = state.selection; + if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds); + if (s?.kind === 'node' || s?.kind === 'annotation') { + const graph = findGraphAsset(state.project, s.assetId)?.graph; + const nodeIds = s.kind === 'node' ? s.nodeIds : []; + const clip = graph && copyGraph(graph, nodeIds, s.annotationIds); + if (!clip) return false; + return put(state, clip, [], counted(clip.nodes.length + clip.annotations.length, 'item')); + } + return false; +} + +function objectClip(state: EditorState): ObjectClip | null { + const clip = state.clipboard?.clip; + return clip?.kind === 'objects' ? clip : null; +} + +function graphClip(state: EditorState): GraphClip | null { + const clip = state.clipboard?.clip; + return clip?.kind === 'graph' ? clip : null; +} + +export function canPasteObjects(state: EditorState): boolean { + return objectClip(state) !== null; +} + +export function canPasteGraph(state: EditorState): boolean { + return graphClip(state) !== null; +} + +/** Whether an object may take children: stored in the holder, and not a container showing its template. */ +function takesChildren(project: Project, holder: ObjectHolder, id: string): boolean { + const object = findObject(holder, id); + return object !== undefined && !isLiveInstance(project, holder, object); +} + +/** The one selected object of `holderId`; empty when there are none or several. */ +function pickedObject(state: EditorState, holderId: string): string { + const s = state.selection; + return s?.kind === 'object' && s.holderId === holderId && s.objectIds.length === 1 ? s.objectId : ''; +} + +/** + * Where each copied root goes. Into the `picked` object when there is one; beside it when that one + * is an original or an earlier paste; otherwise under the parent it had, if this holder has it, + * else on top. + */ +export function pasteParents( + project: Project, + holder: ObjectHolder, + clip: ObjectClip, + lineage: ReadonlySet, + picked: string, +): Record { + let into: string | null = null; + if (picked && lineage.has(picked)) { + const parentId = findObject(holder, picked)?.parentId ?? ''; + into = parentId && takesChildren(project, holder, parentId) ? parentId : ''; + } else if (picked && takesChildren(project, holder, picked)) into = picked; + const parents: Record = {}; + for (const root of clip.roots) { + const own = clip.objects.find((o) => o.id === root.id)?.parentId ?? ''; + parents[root.id] = into ?? (own && takesChildren(project, holder, own) ? own : ''); + } + return parents; +} + +/** + * Pastes the copied objects into `holderId`, into or beside `picked` – by default the one selected + * object. With `pointer` (world units) the copy is centred on it, otherwise it lands a step beside + * the original, a step further for every earlier paste still there. + */ +export function pasteObjects( + state: EditorState, + holderId: string, + pointer: Vec2 | null, + picked = pickedObject(state, holderId), +): boolean { + const board = state.clipboard; + const clip = objectClip(state); + const holder = findObjectHolder(state.project, holderId); + // Nothing of the right kind lets the key through: Ctrl+V then imports an image, as it always did. + if (!board || !clip || !holder) return false; + const centred = + pointer && + centredOn( + pointer, + clip.roots.map((r) => ({ x: r.world.x, y: r.world.y, w: 0, h: 0 })), + ); + const shift = centred + ? { x: Math.round(centred.x), y: Math.round(centred.y) } + : stepped(board.pastes.filter((id) => findObject(holder, id)).length, SCENE_GRID_STEP); + const parents = pasteParents(state.project, holder, clip, board.lineage, picked); + const command = new PasteObjectsCommand(holderId, clip, parents, shift); + if (!state.execute(command)) return false; + board.pastes.push(command.rootIds[0]!); + for (const id of command.rootIds) board.lineage.add(id); + state.select(objectSelection(holderId, command.rootIds)); + return true; +} + +/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */ +export function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean { + return paletteEntries(project, asset).some( + (entry) => + entry.def.type === node.type && + (node.type !== RUN_BEHAVIOR || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]), + ); +} + +/** The part of a graph copy `asset` can take, and the nodes it cannot. */ +export function fittingPart( + project: Project, + asset: GraphAsset, + clip: GraphClip, +): { clip: GraphClip; skipped: GraphNode[] } { + const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n)); + const kept = new Set(nodes.map((n) => n.id)); + return { + clip: { + ...clip, + nodes, + edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)), + }, + skipped: clip.nodes.filter((n) => !kept.has(n.id)), + }; +} + +/** + * Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid + * step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is + * left out and named. + */ +export function pasteGraph( + state: EditorState, + assetId: string, + pointer: Vec2 | null, + snap: boolean, +): boolean { + const board = state.clipboard; + const clip = graphClip(state); + const asset = findGraphAsset(state.project, assetId); + if (!board || !clip || !asset) return false; + const part = fittingPart(state.project, asset, clip); + const skipped = [...new Set(part.skipped.map((n) => nodeDef(n.type)?.label ?? n.type))].join(', '); + if (part.clip.nodes.length === 0 && part.clip.annotations.length === 0) { + state.setNotice(`Nothing of the copy fits into this graph (${skipped})`); + return false; + } + const standing = board.pastes.filter((id) => findNode(asset.graph, id) || findAnnotation(asset.graph, id)); + let shift = stepped(standing.length, GRID_STEP); + if (pointer) { + shift = centredOn(pointer, [ + ...part.clip.nodes.map((n) => ({ x: n.x, y: n.y, w: 0, h: 0 })), + ...part.clip.annotations.map((a) => a.annotation), + ]); + if (snap) shift = { x: snapped(shift.x), y: snapped(shift.y) }; + } + const command = new PasteGraphCommand(assetId, part.clip, shift); + if (!state.execute(command)) return false; + board.pastes.push(command.nodeIds[0] ?? command.annotationIds[0]!); + state.select(graphSelection(assetId, command.nodeIds, command.annotationIds)); + if (skipped) state.setNotice(`Pasted without what this graph cannot take: ${skipped}`); + return true; +} + +function snapped(value: number): number { + return Math.round(value / GRID_STEP) * GRID_STEP; +} + +/** One step down and right per paste still standing, and one more for this one. */ +function stepped(standing: number, step: number): Vec2 { + const by = step * (standing + 1); + return { x: by, y: by }; +} + +/** The shift that puts the middle of the boxes' bounds on `at`. */ +function centredOn(at: Vec2, boxes: readonly { x: number; y: number; w: number; h: number }[]): Vec2 { + const minX = Math.min(...boxes.map((b) => b.x)); + const minY = Math.min(...boxes.map((b) => b.y)); + const maxX = Math.max(...boxes.map((b) => b.x + b.w)); + const maxY = Math.max(...boxes.map((b) => b.y + b.h)); + return { x: at.x - (minX + maxX) / 2, y: at.y - (minY + maxY) / 2 }; +} diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index a3079fce..bbd1331e 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -43,6 +43,7 @@ import { zoomFactor, } from '../../ui'; import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state'; +import { canPasteGraph, copySelection, pasteGraph } from '../clipboard'; import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing'; import { annotationBoxes, boxOf } from './annotationDrag'; import { AnnotationLayer } from './annotationLayer'; @@ -111,6 +112,8 @@ export class GraphCanvas extends Widget { private needsFit = true; private valuesShownAt = 0; private readonly beads = new BeadClock(); + /** Last place the pointer was seen, in global coordinates – where a paste lands. */ + private pointerGlobal: { x: number; y: number } | null = null; /** Supplies live execution data while playing (null otherwise). */ traceProvider: (assetId: string) => Trace | null = () => null; /** Which state each running instance of this machine holds; empty when nothing runs. */ @@ -494,6 +497,7 @@ export class GraphCanvas extends Widget { label: picked > 1 ? `Group these ${picked} nodes` : 'Group this node', onSelect: () => this.groupSelectedNodes(), }, + { label: picked > 1 ? `Copy ${picked} nodes` : 'Copy', onSelect: () => copySelection(this.state) }, ]; openNodeMenu(this.state, this.assetId, this.selectedNodes(), e.global.x, e.global.y, extras); } @@ -539,7 +543,22 @@ export class GraphCanvas extends Widget { return graph.nodes.filter((n) => ids.has(n.id)); } + /** Ctrl+V: under the pointer while it is over this canvas, else a grid step beside the original. */ + paste(): boolean { + return this.assetId ? pasteGraph(this.state, this.assetId, this.pointerGraph(), this.snapping) : false; + } + + /** The pointer in graph units, or null when it is not over this canvas. */ + private pointerGraph(): { x: number; y: number } | null { + const global = this.pointerGlobal; + if (!global) return null; + const local = this.toLocal(global); + if (local.x < 0 || local.y < 0 || local.x > this.w || local.y > this.h) return null; + return this.screenToGraph(local.x, local.y); + } + private onPointerMove(e: FederatedPointerEvent): void { + this.pointerGlobal = { x: e.global.x, y: e.global.y }; const dragged = activeDrag(); if (dragged) { this.trackIncoming(dragged, e); @@ -667,9 +686,20 @@ export class GraphCanvas extends Widget { ).open(globalX, globalY); } - /** The two things the palette offers that are not nodes. Both land where it was opened. */ + /** What the palette offers that is not a node. All of it lands where it was opened. */ private annotationActions(at: { x: number; y: number }): PaletteAction[] { return [ + ...(canPasteGraph(this.state) + ? [ + { + id: 'paste', + label: 'Paste', + hint: 'clipboard', + match: 'paste clipboard', + onSelect: () => void pasteGraph(this.state, this.assetId, at, this.snapping), + }, + ] + : []), { id: 'add-group', label: 'Add group', diff --git a/src/editor/graph/graphMenus.ts b/src/editor/graph/graphMenus.ts index cf68b009..08f7203e 100644 --- a/src/editor/graph/graphMenus.ts +++ b/src/editor/graph/graphMenus.ts @@ -5,6 +5,7 @@ import { annotationNoun } from '../../core/commands/annotationCommands'; import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph'; import { Menu, type MenuItem } from '../../ui'; import { removeGraphSelection, removeNodes } from '../actions'; +import { copySelection } from '../clipboard'; import type { EditorState } from '../state'; /** For the selected nodes; a selection mixing both states offers both directions. */ @@ -56,6 +57,7 @@ export function openAnnotationMenu( const nodes = held ? state.selectedNodeIds : []; const count = boxes.length + nodes.length; new Menu([ + { label: count > 1 ? `Copy ${count} items` : 'Copy', onSelect: () => copySelection(state) }, { label: count > 1 ? `Delete ${count} items` : `Delete ${annotationNoun(kind)}`, danger: true, diff --git a/src/editor/objectMenu.ts b/src/editor/objectMenu.ts index 89a5a2c5..4c4d8940 100644 --- a/src/editor/objectMenu.ts +++ b/src/editor/objectMenu.ts @@ -3,7 +3,7 @@ import { UnpackContainerCommand } from '../core/commands/containerCommands'; import { SetParentCommand } from '../core/commands/sceneObjectCommands'; import { findObject } from '../core/model/hierarchy'; import { isLiveInstance, isTemplateContent } from '../core/model/instance'; -import type { PropertyBag } from '../core/model/Properties'; +import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { findAsset, type ObjectHolder } from '../core/model/Project'; import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema'; import { referencesOf } from '../core/properties/references'; @@ -19,12 +19,14 @@ import { removeObjects, } from './actions'; import { hasAssetEditor } from './assetTabs'; +import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard'; import type { EditorState } from './state'; /** * The menu of a placed object, the same from the hierarchy and the scene view (#204). `source` is * the stored holder, `holder` the one with container content resolved in; `extras` go above - * removing, where only the hierarchy has anything to add. + * removing, where only the hierarchy has anything to add. `pasteAt` is where a paste lands in the + * world – the spot clicked in a view, none from the hierarchy. */ export function openObjectMenu( state: EditorState, @@ -34,6 +36,7 @@ export function openObjectMenu( x: number, y: number, extras: readonly MenuItem[] = [], + pasteAt: Vec2 | null = null, ): Menu { const inScene = holder.type === 'scene'; const object = findObject(holder, id); @@ -64,6 +67,17 @@ export function openObjectMenu( resolveSceneObjectProps(state.project, asset, object), ) : []), + { separator: true }, + { + label: many ? `Copy ${selected.length} objects` : 'Copy', + onSelect: () => copyObjectIds(state, holder.id, selected), + }, + { + label: 'Paste', + disabled: !canPasteObjects(state), + onSelect: () => pasteObjects(state, holder.id, pasteAt, many ? '' : id), + }, + { separator: true }, { label: 'Add collider…', disabled: !object || many || !canAddCollider(state.project, source, object), diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 05a5bd63..217682b8 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -8,9 +8,12 @@ export interface ShortcutActions { play(): void; stop(): void; togglePause(): void; - /** Copy and paste of timeline keys (#102); false when nothing to do there, so the key falls through. */ - copyKeys(): boolean; - pasteKeys(): boolean; + /** + * Copy and paste of what the visible tab edits: keys (#102), objects and graph pieces (#240). + * False when there is nothing to do, so the key falls through. + */ + copy(): boolean; + paste(): boolean; } /** @@ -35,8 +38,8 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions): state.redo(); return true; } - if (mod && key === 'c') return actions.copyKeys(); - if (mod && key === 'v') return actions.pasteKeys(); + if (mod && key === 'c') return actions.copy(); + if (mod && key === 'v') return actions.paste(); if (mod && key === 'g') { const s = state.selection; if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); diff --git a/src/editor/state.ts b/src/editor/state.ts index 4251d4f5..145f3a8e 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -1,4 +1,5 @@ import type { KeyClipboard } from '../core/commands/animationCommands'; +import type { EditorClipboard } from './clipboard'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { type Command, CommandError } from '../core/commands/Command'; import { CommandStack } from '../core/commands/CommandStack'; @@ -225,6 +226,8 @@ export class EditorState { notice = ''; /** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */ keyClipboard: KeyClipboard | null = null; + /** Objects or graph pieces copied with Ctrl+C (#240). Gone with the project, whose ids it holds. */ + clipboard: EditorClipboard | null = null; /** * What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes * on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of @@ -263,6 +266,7 @@ export class EditorState { this.commands = new CommandStack(project); this.selection = null; this.listSlot = null; + this.clipboard = null; this.saveStatus = 'saved'; this.emit('load'); this.emit('project'); diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index d35ec42a..8963afe4 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -26,7 +26,8 @@ import { localMatrixOf, parentTransform, transformOf } from '../../core/properti import { FramePreview } from '../framePreview'; import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; -import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; +import { activeDrag, drawBand, drawGrid, endDrag, Menu, onDragChange, Theme, textStyle } from '../../ui'; +import { canPasteObjects, pasteObjects } from '../clipboard'; import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items'; import { sortRowOf } from '../../viewport/drawOrder'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; @@ -42,7 +43,7 @@ import { type CastRay, RAY_SHOWN, type World } from '../../runtime/World'; import { FreeViewport } from './FreeViewport'; import { PolygonCornerEditor } from './polygonCorners'; import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor'; -import { MIN_GRID_STEP, type SceneViewHost } from './sceneViewHost'; +import { MIN_GRID_STEP, SCENE_GRID_STEP, type SceneViewHost } from './sceneViewHost'; import { type PaintTarget, TilemapPainter } from './tilemapPainting'; import { TilemapSolidOutlines } from './tilemapSolids'; import type { Frame } from './WorldViewport'; @@ -50,7 +51,6 @@ import type { Frame } from './WorldViewport'; /** Pixels of travel before a press turns into a drag. */ const MARQUEE_THRESHOLD = 3; const DRAG_THRESHOLD = 2; -const GRID_STEP = 100; interface ObjectDrag { holderId: string; @@ -148,6 +148,8 @@ export class SceneViewport extends FreeViewport { private lastGizmos: GizmoInfo[] = []; /** Ids that got an owner link this frame (test hook). */ private lastLinks: string[] = []; + /** Last place the pointer was seen, in global coordinates – where a paste lands. */ + private pointerGlobal: Vec2 | null = null; constructor(state: EditorState) { super(state); @@ -222,11 +224,44 @@ export class SceneViewport extends FreeViewport { return { layerId: paintedLayerId(this.state, target.asset), mode }; } + /** Where Ctrl+V puts objects: under the pointer while it is over this view, else nowhere in particular. */ + pasteHere(): boolean { + const holder = this.holder(); + return holder ? pasteObjects(this.state, holder.id, this.pointerWorld()) : false; + } + + /** The pointer in world units, or null when it is not over this view. */ + private pointerWorld(): Vec2 | null { + const global = this.pointerGlobal; + if (!global) return null; + const local = this.toLocal(global); + if (local.x < 0 || local.y < 0 || local.x > this.w || local.y > this.h) return null; + return screenToWorld(this.currentView(), local.x, local.y); + } + + /** Empty space has nothing to act on but the clipboard. */ + private openEmptyMenu(e: FederatedPointerEvent): void { + const holder = this.holder(); + if (!holder) return; + const at = this.host.worldAt(e.global); + new Menu([ + { + label: 'Paste', + disabled: !canPasteObjects(this.state), + onSelect: () => pasteObjects(this.state, holder.id, at), + }, + ]).open(e.global.x, e.global.y); + } + private onPointerDown(e: FederatedPointerEvent): void { if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { this.startPan(e); return; } + if (e.button === 2 && e.target === this) { + this.openEmptyMenu(e); + return; + } if (e.button === 0 && (this.corners.press(e) || this.sizer.press(e))) return; if (e.button === 0 && e.target === this) this.pressEmpty(e); } @@ -247,6 +282,7 @@ export class SceneViewport extends FreeViewport { } private onPointerMove(e: FederatedPointerEvent): void { + this.pointerGlobal = { x: e.global.x, y: e.global.y }; const incoming = activeDrag(); if (incoming) { this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e); @@ -447,11 +483,15 @@ export class SceneViewport extends FreeViewport { this.marquee ) return; - const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId()); - if (!target) return; e.stopPropagation(); + const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId()); + if (!target) { + this.openEmptyMenu(e); + return; + } if (target.select) this.state.select(objectSelection(holder.id, [target.id])); - openObjectMenu(this.state, source, holder, target.id, e.global.x, e.global.y); + const at = this.host.worldAt(e.global); + openObjectMenu(this.state, source, holder, target.id, e.global.x, e.global.y, [], at); } private primaryObjectId(): string | null { @@ -759,7 +799,7 @@ export class SceneViewport extends FreeViewport { private syncGrid(): void { this.grid.clear(); - const step = GRID_STEP * this.worldScale(); + const step = SCENE_GRID_STEP * this.worldScale(); if (step < MIN_GRID_STEP) return; const origin = worldToScreen(this.currentView(), 0, 0); drawGrid(this.grid, { diff --git a/src/editor/viewport/sceneViewHost.ts b/src/editor/viewport/sceneViewHost.ts index 8fddb650..3b5dc75f 100644 --- a/src/editor/viewport/sceneViewHost.ts +++ b/src/editor/viewport/sceneViewHost.ts @@ -6,6 +6,8 @@ import type { EditorState } from '../state'; /** Below this on-screen spacing a grid is noise rather than orientation, so it is dropped. */ export const MIN_GRID_STEP = 12; +/** Spacing of the scene view's grid, in world units. */ +export const SCENE_GRID_STEP = 100; /** What the scene view lends the tools that work inside it. */ export interface SceneViewHost { diff --git a/tests/editor/clipboard.test.ts b/tests/editor/clipboard.test.ts new file mode 100644 index 00000000..cb13df6c --- /dev/null +++ b/tests/editor/clipboard.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { AddAnnotationCommand } from '../../src/core/commands/annotationCommands'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import type { Command } from '../../src/core/commands/Command'; +import { CreateContainerCommand } from '../../src/core/commands/containerCommands'; +import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; +import { BEHAVIOR_PARAM, BEHAVIOR_START, RUN_BEHAVIOR, STATE } from '../../src/core/graph'; +import type { BehaviorAsset, ContainerAsset, StateMachineAsset } from '../../src/core/model/Asset'; +import { findObject } from '../../src/core/model/hierarchy'; +import { instanceChildId, resolvedHolder } from '../../src/core/model/instance'; +import type { Vec2 } from '../../src/core/model/Properties'; +import { createEmptyProject, findAsset, findScene } from '../../src/core/model/Project'; +import { worldPosition } from '../../src/core/properties/transform'; +import { copySelection, pasteGraph, pasteObjects } from '../../src/editor/clipboard'; +import { EditorState, nodeSelection, objectSelection } from '../../src/editor/state'; + +function run(state: EditorState, command: C): C { + // A rejected setup step only returns false and would let later checks pass for the wrong reason. + expect(state.execute(command)).toBe(true); + return command; +} + +/** A scene with a sprite "Box" and a parent box `a` at (100, 100) holding `c` at world (110, 100). */ +function scene() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const sceneId = run(state, new CreateAssetCommand('scene', 'Level')).asset.id; + const spriteId = run(state, new CreateAssetCommand('sprite', 'Box')).asset.id; + const place = (at: Vec2, parentId = '') => + run(state, new PlaceAssetCommand(sceneId, spriteId, at, parentId)).objectId; + const a = place({ x: 100, y: 100 }); + const c = place({ x: 110, y: 100 }, a); + const level = () => findScene(state.project, sceneId)!; + const view = () => resolvedHolder(state.project, level()); + const world = (id: string) => worldPosition(state.project, view(), findObject(view(), id)!); + const copy = (ids: string[]) => { + state.select(objectSelection(sceneId, ids)); + expect(copySelection(state)).toBe(true); + }; + return { state, sceneId, spriteId, place, a, c, level, view, world, copy }; +} + +describe('copy and paste of scene objects', () => { + it('pastes the whole subtree with fresh ids, a grid step beside the original', () => { + const f = scene(); + f.copy([f.a]); + f.state.select(null); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + const [root] = f.state.selectedObjectIds; + expect(root).toBeDefined(); + expect(root).not.toBe(f.a); + expect(f.world(root!)).toMatchObject({ x: 200, y: 200 }); + const child = f.level().objects.find((o) => o.parentId === root)!; + expect(child.id).not.toBe(f.c); + expect(child.position).toEqual(findObject(f.level(), f.c)!.position); + expect(f.level().objects).toHaveLength(4); + f.state.undo(); + expect(f.level().objects.map((o) => o.id)).toEqual([f.a, f.c]); + }); + + it('centres the copied roots on the pointer', () => { + const f = scene(); + const b = f.place({ x: 300, y: 100 }); + f.copy([f.a, b]); + f.state.select(null); + expect(pasteObjects(f.state, f.sceneId, { x: 500, y: 500 })).toBe(true); + const [first, second] = f.state.selectedObjectIds; + expect(f.world(first!)).toMatchObject({ x: 400, y: 500 }); + expect(f.world(second!)).toMatchObject({ x: 600, y: 500 }); + }); + + it('pastes into the one selected object and keeps the look under a turned parent', () => { + const f = scene(); + const target = f.place({ x: 0, y: 0 }); + run(f.state, new SetOverrideCommand(f.sceneId, target, 'rotation', 90)); + f.copy([f.c]); + f.state.select(objectSelection(f.sceneId, [target])); + expect(pasteObjects(f.state, f.sceneId, { x: 50, y: 0 })).toBe(true); + const pasted = findObject(f.level(), f.state.selectedObjectIds[0]!)!; + expect(pasted.parentId).toBe(target); + expect(f.world(pasted.id).x).toBeCloseTo(50); + expect(f.world(pasted.id).y).toBeCloseTo(0); + expect(pasted.overrides.rotation).toBeCloseTo(-90); + }); + + it('puts the copy beside a selected original or earlier paste, a step further each time', () => { + const f = scene(); + f.copy([f.c]); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + const first = f.state.selectedObjectIds[0]!; + expect(findObject(f.level(), first)!.parentId).toBe(f.a); + expect(f.world(first)).toMatchObject({ x: 210, y: 200 }); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + const second = f.state.selectedObjectIds[0]!; + expect(findObject(f.level(), second)!.parentId).toBe(f.a); + expect(f.world(second)).toMatchObject({ x: 310, y: 300 }); + }); + + it('lets an undone paste give its spot back to the next one', () => { + const f = scene(); + f.copy([f.a]); + pasteObjects(f.state, f.sceneId, null); + f.state.undo(); + expect(pasteObjects(f.state, f.sceneId, null)).toBe(true); + expect(f.world(f.state.selectedObjectIds[0]!)).toMatchObject({ x: 200, y: 200 }); + }); + + it('keeps a copied container showing its template, and copies picked-out content as objects', () => { + const f = scene(); + const create = run(f.state, new CreateContainerCommand(f.sceneId, [f.a])); + const containerId = create.objectId; + const template = findAsset(f.state.project, create.assetId) as ContainerAsset; + f.copy([containerId]); + f.state.select(null); + pasteObjects(f.state, f.sceneId, null); + expect(f.level().objects).toHaveLength(2); + const instance = f.state.selectedObjectIds[0]!; + expect(findObject(f.level(), instance)!.assetId).toBe(create.assetId); + expect(f.view().objects.filter((o) => o.id.startsWith(`${instance}~`))).toHaveLength(2); + + const content = instanceChildId(containerId, template.objects[0]!.id); + f.copy([content]); + f.state.select(null); + pasteObjects(f.state, f.sceneId, null); + const pasted = f.state.selectedObjectIds[0]!; + expect(findObject(f.level(), pasted)!.assetId).toBe(f.spriteId); + expect(f.level().objects.filter((o) => o.parentId === pasted)).toHaveLength(1); + }); + + it('refuses a container pasted into its own template, and changes nothing', () => { + const f = scene(); + const create = run(f.state, new CreateContainerCommand(f.sceneId, [f.a])); + f.copy([create.objectId]); + f.state.select(null); + const before = JSON.stringify(findAsset(f.state.project, create.assetId)); + expect(pasteObjects(f.state, create.assetId, null)).toBe(false); + expect(f.state.notice).toContain('nests forever'); + expect(JSON.stringify(findAsset(f.state.project, create.assetId))).toBe(before); + }); + + it('keeps a copied child with its original parent when it is pasted without a target', () => { + const f = scene(); + f.copy([f.c]); + f.state.select(null); + pasteObjects(f.state, f.sceneId, null); + expect(findObject(f.level(), f.state.selectedObjectIds[0]!)!.parentId).toBe(f.a); + }); + + it('forgets the clipboard with the project', () => { + const f = scene(); + f.copy([f.a]); + expect(f.state.clipboard).not.toBeNull(); + f.state.loadProject(createEmptyProject('q', 'Q'), 1); + expect(f.state.clipboard).toBeNull(); + }); +}); + +/** A behavior holding OnUpdate → Log plus a lone Number, a comment, and a second, empty behavior. */ +function graphs() { + const state = new EditorState(); + state.loadProject(createEmptyProject('p', 'P'), 1); + const behavior = run(state, new CreateAssetCommand('behavior', 'B')).asset as BehaviorAsset; + const other = run(state, new CreateAssetCommand('behavior', 'Other')).asset as BehaviorAsset; + const machine = run(state, new CreateAssetCommand('statemachine', 'M')).asset as StateMachineAsset; + const add = (assetId: string, type: string, x: number, y: number, params = {}) => + run(state, new AddNodeCommand(assetId, type, x, y, params)).node; + const update = add(behavior.id, 'OnUpdate', 0, 0); + const log = add(behavior.id, 'Log', 240, 0); + const lone = add(behavior.id, 'Number', 0, 120); + run( + state, + new ConnectCommand(behavior.id, { node: update.id, port: 'exec' }, { node: log.id, port: 'exec' }), + ); + run( + state, + new ConnectCommand(behavior.id, { node: lone.id, port: 'value' }, { node: log.id, port: 'message' }), + ); + const note = run( + state, + new AddAnnotationCommand(behavior.id, 'note', { x: 0, y: 300, w: 200, h: 96 }, 'Hi'), + ); + return { state, behavior, other, machine, add, update, log, lone, note: note.annotation }; +} + +describe('copy and paste of graph nodes', () => { + it('copies the nodes, the links between them and the boxes, a grid step beside the original', () => { + const f = graphs(); + f.state.select(nodeSelection(f.behavior.id, [f.update.id, f.log.id], undefined, [f.note.id])); + expect(copySelection(f.state)).toBe(true); + expect(pasteGraph(f.state, f.other.id, null, false)).toBe(true); + const graph = f.other.graph; + expect(graph.nodes.map((n) => [n.type, n.x, n.y])).toEqual([ + ['OnUpdate', 24, 24], + ['Log', 264, 24], + ]); + expect(graph.nodes.some((n) => n.id === f.update.id)).toBe(false); + // The link from the lone Number stays behind with it. + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]!.from.node).toBe(graph.nodes[0]!.id); + expect(graph.edges[0]!.to.node).toBe(graph.nodes[1]!.id); + expect(graph.notes?.map((n) => [n.text, n.x, n.y])).toEqual([['Hi', 24, 324]]); + expect(f.state.selectedNodeIds).toEqual(graph.nodes.map((n) => n.id)); + expect(f.state.selectedAnnotationIds).toEqual([graph.notes![0]!.id]); + f.state.undo(); + expect(f.other.graph.nodes).toEqual([]); + expect(f.other.graph.edges).toEqual([]); + expect(f.other.graph.notes).toEqual([]); + }); + + it('leaves out what the target graph cannot take and says so', () => { + const f = graphs(); + f.state.select(nodeSelection(f.behavior.id, [f.update.id, f.log.id])); + copySelection(f.state); + expect(pasteGraph(f.state, f.machine.id, null, false)).toBe(false); + expect(f.state.notice).toContain('Nothing of the copy fits'); + expect(f.machine.graph.nodes).toEqual([]); + + const state = f.add(f.machine.id, STATE, 0, 0); + f.state.select(nodeSelection(f.machine.id, [state.id])); + copySelection(f.state); + expect(pasteGraph(f.state, f.behavior.id, null, false)).toBe(false); + expect(f.machine.graph.nodes).toHaveLength(1); + }); + + it('skips a second terminal and a behavior calling itself, pasting the rest', () => { + const f = graphs(); + const start = f.add(f.other.id, BEHAVIOR_START, 0, 0); + const call = f.add(f.other.id, RUN_BEHAVIOR, 0, 100, { [BEHAVIOR_PARAM]: f.behavior.id }); + const number = f.add(f.other.id, 'Number', 0, 200); + f.add(f.behavior.id, BEHAVIOR_START, 0, 400); + f.state.select(nodeSelection(f.other.id, [start.id, call.id, number.id])); + copySelection(f.state); + const before = f.behavior.graph.nodes.length; + expect(pasteGraph(f.state, f.behavior.id, null, false)).toBe(true); + expect(f.behavior.graph.nodes.slice(before).map((n) => n.type)).toEqual(['Number']); + expect(f.state.notice).toContain('Start'); + expect(f.state.notice).toContain('Run behavior'); + }); + + it('puts a pointer paste on the grid when snapping is on', () => { + const f = graphs(); + f.state.select(nodeSelection(f.behavior.id, [f.lone.id])); + copySelection(f.state); + expect(pasteGraph(f.state, f.other.id, { x: 101, y: 50 }, true)).toBe(true); + expect(f.other.graph.nodes.map((n) => [n.x, n.y])).toEqual([[96, 48]]); + expect(pasteGraph(f.state, f.other.id, { x: 101, y: 50 }, false)).toBe(true); + expect(f.other.graph.nodes[1]).toMatchObject({ x: 101, y: 50 }); + }); +}); diff --git a/tools/e2e/51-clipboard.mjs b/tools/e2e/51-clipboard.mjs new file mode 100644 index 00000000..aa99dd1e --- /dev/null +++ b/tools/e2e/51-clipboard.mjs @@ -0,0 +1,131 @@ +/** + * Copy and paste of scene objects and graph nodes (#240), with the real keys and the real menus. + * What only the running editor knows is where the pointer is: a paste under it has to land exactly + * there, and one without it has to land beside the original – the rules themselves are unit-tested. + * Everything pasted here is taken back by undo, so the fixture leaves as it came. + */ +export async function clipboard(t) { + const { check, json, evaluate, mouse, settle, press, click, clickText, select, showTab, undo, shot } = t; + + const stored = () => json('__gm.state.activeScene.objects'); + const selected = async () => (await json('__gm.state.selection')) ?? {}; + const near = (a, b) => a && b && Math.abs(a.x - b.x) <= 1.5 && Math.abs(a.y - b.y) <= 1.5; + const moveTo = async (p) => { + await mouse('mouseMoved', p.x, p.y); + await settle(); + }; + + await press('Escape'); + await showTab('scene'); + await evaluate('__gm.state.clipboard = null'); + + // 51a. Ctrl+C on the blue crate, Ctrl+V with the pointer over empty space: the crate and its + // collider come back as new objects, the crate standing right under the pointer. + await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o2'] }); + await press('c', { ctrl: true }); + const spot = await json('__gm.emptySpot()'); + const before = await stored(); + await moveTo(spot); + await press('v', { ctrl: true }); + const afterPointer = await stored(); + const pasted = (await selected()).objectId; + const copy = afterPointer.find((o) => o.id === pasted); + const copiedCollider = afterPointer.find((o) => o.parentId === pasted); + check( + 'Ctrl+V puts a copy of the crate and its collider under the pointer', + afterPointer.length === before.length + 2 && + copy?.assetId === 'crate' && + copy.name === 'Blue crate' && + copiedCollider?.assetId === 'collider' && + near(await json(`__gm.objectScreen(${JSON.stringify(pasted)})`), spot), + JSON.stringify({ spot, copy, at: await json(`__gm.objectScreen(${JSON.stringify(pasted)})`) }), + ); + check( + 'the hierarchy lists the copy', + (await json('__gm.hierarchyOrder()')).includes(pasted), + JSON.stringify(await json('__gm.hierarchyOrder()')), + ); + await shot('51-pasted-at-pointer'); + await undo(); + check('one undo takes the whole paste back', (await stored()).length === before.length); + + // 51b. With the pointer over the hierarchy the copy has nowhere to be aimed: it lands one grid + // step down and right of the original, beside it rather than inside it. + await select({ kind: 'object', holderId: 'scene1', objectId: 'o2', objectIds: ['o2'] }); + await moveTo(await json('__gm.hierarchyRow("o2")')); + await press('v', { ctrl: true }); + const besideId = (await selected()).objectId; + const beside = (await stored()).find((o) => o.id === besideId); + const original = (await stored()).find((o) => o.id === 'o2'); + check( + 'without the pointer on the view the copy lands a grid step beside the original', + beside !== undefined && + beside.id !== 'o2' && + beside.parentId === original.parentId && + beside.position.x === original.position.x + 100 && + beside.position.y === original.position.y + 100, + JSON.stringify({ original: original.position, beside }), + ); + await undo(); + + // 51c. The object menu: right-click the red crate, "Paste" puts the copy inside what was hit – + // the crate or the collider drawn over it, whichever the pointer picks. + const red = await json('__gm.objectScreen("o1")'); + await click(red.x, red.y, 'right'); + const target = (await selected()).objectId; + const rows = (await json('__gm.menuRows()')) ?? []; + await clickText('Paste'); + const insideId = (await selected()).objectId; + const inside = (await stored()).find((o) => o.id === insideId); + check( + 'the object menu offers Copy and Paste, and Paste puts the copy into the object clicked', + rows.includes('Copy') && + rows.includes('Paste') && + ['o1', 'oc1'].includes(target) && + inside?.assetId === 'crate' && + inside.parentId === target, + JSON.stringify({ target, rows, inside }), + ); + await undo(); + check('the scene is back as it was', (await stored()).length === before.length); + + // 51d. A node of the Mover behavior, copied and pasted under the pointer in its own graph. + await select({ kind: 'node', assetId: 'mover', nodeId: 'n-axis', nodeIds: ['n-axis'] }); + await showTab('graph'); + await press('c', { ctrl: true }); + const graphSpot = await json('__gm.graph.emptySpot()'); + const nodes = () => json("__gm.state.project.assets.find((a) => a.id === 'mover').graph.nodes"); + const count = (await nodes()).length; + await moveTo(graphSpot); + await press('v', { ctrl: true }); + const newNode = (await selected()).nodeId; + const zoom = await json('__gm.graph.zoom()'); + const title = await json(`__gm.graph.nodeAt(${JSON.stringify(newNode)})`); + // `nodeAt` is the middle of the title bar; the node's corner is what lands on the pointer. + const corner = title && { x: title.x - (176 / 2) * zoom, y: title.y - (24 / 2) * zoom }; + check( + 'Ctrl+V in the graph puts a copy of the node under the pointer', + (await nodes()).length === count + 1 && + (await nodes()).find((n) => n.id === newNode)?.type === 'AxisInput' && + near(corner, graphSpot), + JSON.stringify({ graphSpot, corner, zoom }), + ); + await undo(); + + // 51e. The palette on empty space offers Paste first, and it lands where the palette opened. + await click(graphSpot.x, graphSpot.y, 'right'); + const offered = await json('__gm.graph.paletteRows()'); + await clickText('Paste'); + const fromPalette = (await selected()).nodeId; + check( + 'the palette on empty space offers Paste, which adds the copy there', + offered[0]?.type === 'paste' && + (await nodes()).length === count + 1 && + (await nodes()).find((n) => n.id === fromPalette)?.type === 'AxisInput', + JSON.stringify(offered.slice(0, 3)), + ); + await undo(); + check('the graph is back as it was', (await nodes()).length === count); + await evaluate('__gm.state.clipboard = null'); + await showTab('scene'); +} diff --git a/tools/e2e/index.mjs b/tools/e2e/index.mjs index c88cc085..ffb2ec2d 100644 --- a/tools/e2e/index.mjs +++ b/tools/e2e/index.mjs @@ -53,6 +53,7 @@ import { vectorArt } from './47-vectorart.mjs'; import { pixelArt } from './48-pixelart.mjs'; import { users } from './49-users.mjs'; import { snapshots } from './50-snapshots.mjs'; +import { clipboard } from './51-clipboard.mjs'; export const STEPS = [ hierarchy, @@ -105,4 +106,5 @@ export const STEPS = [ pixelArt, users, snapshots, + clipboard, ];