Copy and paste scene objects and graph nodes (#240)
game-maker / gate (push) Successful in 3m19s
game-maker / suite (push) Failing after 9m5s

Copy and paste only existed for animation keys. Building a scene or a
graph meant placing every object and node again by hand, even when the
same arrangement was needed twice.

- Ctrl+C copies the selected objects with their whole subtree, or the
  selected nodes with their links, groups and comments. Ctrl+V pastes
  into the scene, container or graph of the visible tab.
- The copy is a snapshot in the editor's own clipboard, in memory. It
  holds asset ids, so the system clipboard is not used. It is cleared
  when another project opens.
- A paste lands under the mouse pointer when it is over the view, and
  keeps its arrangement. Otherwise it lands one grid step beside the
  original. Each further copy that still exists moves one step on, so
  an undone copy does not count.
- Objects get new ids for the whole subtree. They paste into the one
  selected object and keep their look under the new parent. If that
  object is the original or an earlier copy, the paste lands beside it
  instead of nesting deeper with every Ctrl+V.
- A placed container stays a container. Content copied out of a
  container pastes as plain objects. Pasting a container into its own
  template is refused.
- Nodes paste only where the palette would offer them. A second Start
  or End, a State in a behavior or a behavior calling itself is left
  out, and a notice names what was dropped.
- Menus: Copy and Paste in the object menu, a new Paste menu on empty
  scene space, Copy in the node and box menus, Paste in the palette.
- Ctrl+V still imports a picture from the system clipboard when the
  copy does not fit the tab. While objects are copied, the scene and
  container tabs use Ctrl+V for them, as the timeline does for keys.

Not checked in the browser: paste in the container tab, in state
machine, shader and particle graphs, and from the hierarchy menu. The
shared code path covers them.

Tests:
- ./check.sh: 175 files, 2531 tests, CHECK EXIT 0
- e2e 51 (new): ALL PASSED, 11 checks with real keys and clicks
- 13 unit tests in tests/editor/clipboard.test.ts, one of them pins
  the grid step after undo found during e2e
- 7 counter-checks all fail as expected (6 unit, 1 in the browser)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-27 00:25:51 +02:00
1 parent 7e829f737e
commit 2b89292eb4
15 files changed
+1032 -18

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+164
View File
@@ -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<string, string> {
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<string, string>;
constructor(
private readonly holderId: string,
private readonly clip: ObjectClip,
private readonly parents: Readonly<Record<string, string>>,
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<string, string>;
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<string>): 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));
}
+108
View File
@@ -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,
});
}
+26 -2
View File
@@ -66,6 +66,7 @@ import { StateMachineEditor } from './statemachine/StateMachineEditor';
import { installShortcuts } from './shortcuts'; import { installShortcuts } from './shortcuts';
import type { PlayFrom } from '../core/model/playSettings'; import type { PlayFrom } from '../core/model/playSettings';
import type { EditorState } from './state'; import type { EditorState } from './state';
import { copySelection } from './clipboard';
import { PixelArtEditor } from './pixelart/PixelArtEditor'; import { PixelArtEditor } from './pixelart/PixelArtEditor';
import { VectorArtEditor } from './vectorart/VectorArtEditor'; import { VectorArtEditor } from './vectorart/VectorArtEditor';
import { TextureEditor } from './texture/TextureEditor'; import { TextureEditor } from './texture/TextureEditor';
@@ -436,8 +437,9 @@ export class Editor implements PlayHost {
play: () => this.play.play(), play: () => this.play.play(),
stop: () => this.play.stop(), stop: () => this.play.stop(),
togglePause: () => this.play.togglePause(), togglePause: () => this.play.togglePause(),
copyKeys: () => this.tab === 'animation' && this.animationEditor.canvas.copyKeys(), copy: () =>
pasteKeys: () => this.tab === 'animation' && this.animationEditor.canvas.pasteKeys(), (this.tab === 'animation' && this.animationEditor.canvas.copyKeys()) || copySelection(state),
paste: () => this.paste(),
}), }),
); );
const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000); const onTick = (ticker: Ticker) => this.tick(ticker.deltaMS / 1000);
@@ -477,6 +479,28 @@ export class Editor implements PlayHost {
state.on('mode', () => this.editorRun.invalidate()); 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 * 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. * for everybody – both open the one tab, which holds the plugins below the accounts.
+240
View File
@@ -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<string>;
pastes: string[];
}
function put(state: EditorState, clip: Clip, lineage: Iterable<string>, 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<string>,
picked: string,
): Record<string, string> {
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<string, string> = {};
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 };
}
+31 -1
View File
@@ -43,6 +43,7 @@ import {
zoomFactor, zoomFactor,
} from '../../ui'; } from '../../ui';
import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state'; import { annotationSelection, type EditorState, graphSelection, nodeSelection } from '../state';
import { canPasteGraph, copySelection, pasteGraph } from '../clipboard';
import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing'; import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '../framing';
import { annotationBoxes, boxOf } from './annotationDrag'; import { annotationBoxes, boxOf } from './annotationDrag';
import { AnnotationLayer } from './annotationLayer'; import { AnnotationLayer } from './annotationLayer';
@@ -111,6 +112,8 @@ export class GraphCanvas extends Widget {
private needsFit = true; private needsFit = true;
private valuesShownAt = 0; private valuesShownAt = 0;
private readonly beads = new BeadClock(); 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). */ /** Supplies live execution data while playing (null otherwise). */
traceProvider: (assetId: string) => Trace | null = () => null; traceProvider: (assetId: string) => Trace | null = () => null;
/** Which state each running instance of this machine holds; empty when nothing runs. */ /** 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', label: picked > 1 ? `Group these ${picked} nodes` : 'Group this node',
onSelect: () => this.groupSelectedNodes(), 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); 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)); 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 { private onPointerMove(e: FederatedPointerEvent): void {
this.pointerGlobal = { x: e.global.x, y: e.global.y };
const dragged = activeDrag(); const dragged = activeDrag();
if (dragged) { if (dragged) {
this.trackIncoming(dragged, e); this.trackIncoming(dragged, e);
@@ -667,9 +686,20 @@ export class GraphCanvas extends Widget {
).open(globalX, globalY); ).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[] { private annotationActions(at: { x: number; y: number }): PaletteAction[] {
return [ 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', id: 'add-group',
label: 'Add group', label: 'Add group',
+2
View File
@@ -5,6 +5,7 @@ import { annotationNoun } from '../../core/commands/annotationCommands';
import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph'; import type { AnnotationKind, GraphEdge, GraphNode } from '../../core/graph';
import { Menu, type MenuItem } from '../../ui'; import { Menu, type MenuItem } from '../../ui';
import { removeGraphSelection, removeNodes } from '../actions'; import { removeGraphSelection, removeNodes } from '../actions';
import { copySelection } from '../clipboard';
import type { EditorState } from '../state'; import type { EditorState } from '../state';
/** For the selected nodes; a selection mixing both states offers both directions. */ /** 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 nodes = held ? state.selectedNodeIds : [];
const count = boxes.length + nodes.length; const count = boxes.length + nodes.length;
new Menu([ new Menu([
{ label: count > 1 ? `Copy ${count} items` : 'Copy', onSelect: () => copySelection(state) },
{ {
label: count > 1 ? `Delete ${count} items` : `Delete ${annotationNoun(kind)}`, label: count > 1 ? `Delete ${count} items` : `Delete ${annotationNoun(kind)}`,
danger: true, danger: true,
+16 -2
View File
@@ -3,7 +3,7 @@ import { UnpackContainerCommand } from '../core/commands/containerCommands';
import { SetParentCommand } from '../core/commands/sceneObjectCommands'; import { SetParentCommand } from '../core/commands/sceneObjectCommands';
import { findObject } from '../core/model/hierarchy'; import { findObject } from '../core/model/hierarchy';
import { isLiveInstance, isTemplateContent } from '../core/model/instance'; 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 { findAsset, type ObjectHolder } from '../core/model/Project';
import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema'; import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema';
import { referencesOf } from '../core/properties/references'; import { referencesOf } from '../core/properties/references';
@@ -19,12 +19,14 @@ import {
removeObjects, removeObjects,
} from './actions'; } from './actions';
import { hasAssetEditor } from './assetTabs'; import { hasAssetEditor } from './assetTabs';
import { canPasteObjects, copyObjectIds, pasteObjects } from './clipboard';
import type { EditorState } from './state'; import type { EditorState } from './state';
/** /**
* The menu of a placed object, the same from the hierarchy and the scene view (#204). `source` is * 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 * 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( export function openObjectMenu(
state: EditorState, state: EditorState,
@@ -34,6 +36,7 @@ export function openObjectMenu(
x: number, x: number,
y: number, y: number,
extras: readonly MenuItem[] = [], extras: readonly MenuItem[] = [],
pasteAt: Vec2 | null = null,
): Menu { ): Menu {
const inScene = holder.type === 'scene'; const inScene = holder.type === 'scene';
const object = findObject(holder, id); const object = findObject(holder, id);
@@ -64,6 +67,17 @@ export function openObjectMenu(
resolveSceneObjectProps(state.project, asset, object), 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…', label: 'Add collider…',
disabled: !object || many || !canAddCollider(state.project, source, object), disabled: !object || many || !canAddCollider(state.project, source, object),
+8 -5
View File
@@ -8,9 +8,12 @@ export interface ShortcutActions {
play(): void; play(): void;
stop(): void; stop(): void;
togglePause(): void; togglePause(): void;
/** Copy and paste of timeline keys (#102); false when nothing to do there, so the key falls through. */ /**
copyKeys(): boolean; * Copy and paste of what the visible tab edits: keys (#102), objects and graph pieces (#240).
pasteKeys(): boolean; * 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(); state.redo();
return true; return true;
} }
if (mod && key === 'c') return actions.copyKeys(); if (mod && key === 'c') return actions.copy();
if (mod && key === 'v') return actions.pasteKeys(); if (mod && key === 'v') return actions.paste();
if (mod && key === 'g') { if (mod && key === 'g') {
const s = state.selection; const s = state.selection;
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds); if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
+4
View File
@@ -1,4 +1,5 @@
import type { KeyClipboard } from '../core/commands/animationCommands'; import type { KeyClipboard } from '../core/commands/animationCommands';
import type { EditorClipboard } from './clipboard';
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation'; import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
import { type Command, CommandError } from '../core/commands/Command'; import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack'; import { CommandStack } from '../core/commands/CommandStack';
@@ -225,6 +226,8 @@ export class EditorState {
notice = ''; notice = '';
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */ /** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
keyClipboard: KeyClipboard | null = null; 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 * 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 * 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.commands = new CommandStack(project);
this.selection = null; this.selection = null;
this.listSlot = null; this.listSlot = null;
this.clipboard = null;
this.saveStatus = 'saved'; this.saveStatus = 'saved';
this.emit('load'); this.emit('load');
this.emit('project'); this.emit('project');
+47 -7
View File
@@ -26,7 +26,8 @@ import { localMatrixOf, parentTransform, transformOf } from '../../core/properti
import { FramePreview } from '../framePreview'; import { FramePreview } from '../framePreview';
import { type EditorState, objectSelection } from '../state'; import { type EditorState, objectSelection } from '../state';
import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; 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 { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items';
import { sortRowOf } from '../../viewport/drawOrder'; import { sortRowOf } from '../../viewport/drawOrder';
import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; 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 { FreeViewport } from './FreeViewport';
import { PolygonCornerEditor } from './polygonCorners'; import { PolygonCornerEditor } from './polygonCorners';
import { SizeGizmoEditor, type SizeGizmoInfo } from './sizeGizmoEditor'; 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 { type PaintTarget, TilemapPainter } from './tilemapPainting';
import { TilemapSolidOutlines } from './tilemapSolids'; import { TilemapSolidOutlines } from './tilemapSolids';
import type { Frame } from './WorldViewport'; import type { Frame } from './WorldViewport';
@@ -50,7 +51,6 @@ import type { Frame } from './WorldViewport';
/** Pixels of travel before a press turns into a drag. */ /** Pixels of travel before a press turns into a drag. */
const MARQUEE_THRESHOLD = 3; const MARQUEE_THRESHOLD = 3;
const DRAG_THRESHOLD = 2; const DRAG_THRESHOLD = 2;
const GRID_STEP = 100;
interface ObjectDrag { interface ObjectDrag {
holderId: string; holderId: string;
@@ -148,6 +148,8 @@ export class SceneViewport extends FreeViewport {
private lastGizmos: GizmoInfo[] = []; private lastGizmos: GizmoInfo[] = [];
/** Ids that got an owner link this frame (test hook). */ /** Ids that got an owner link this frame (test hook). */
private lastLinks: string[] = []; private lastLinks: string[] = [];
/** Last place the pointer was seen, in global coordinates – where a paste lands. */
private pointerGlobal: Vec2 | null = null;
constructor(state: EditorState) { constructor(state: EditorState) {
super(state); super(state);
@@ -222,11 +224,44 @@ export class SceneViewport extends FreeViewport {
return { layerId: paintedLayerId(this.state, target.asset), mode }; 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 { private onPointerDown(e: FederatedPointerEvent): void {
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) { if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
this.startPan(e); this.startPan(e);
return; 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 && (this.corners.press(e) || this.sizer.press(e))) return;
if (e.button === 0 && e.target === this) this.pressEmpty(e); if (e.button === 0 && e.target === this) this.pressEmpty(e);
} }
@@ -247,6 +282,7 @@ export class SceneViewport extends FreeViewport {
} }
private onPointerMove(e: FederatedPointerEvent): void { private onPointerMove(e: FederatedPointerEvent): void {
this.pointerGlobal = { x: e.global.x, y: e.global.y };
const incoming = activeDrag(); const incoming = activeDrag();
if (incoming) { if (incoming) {
this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e); this.trackIncomingAsset(incoming.kind === 'asset' ? incoming.id : '', e);
@@ -447,11 +483,15 @@ export class SceneViewport extends FreeViewport {
this.marquee this.marquee
) )
return; return;
const target = menuTargetOf(this.stackUnder(e), this.state.selectedObjectIds, this.primaryObjectId());
if (!target) return;
e.stopPropagation(); 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])); 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 { private primaryObjectId(): string | null {
@@ -759,7 +799,7 @@ export class SceneViewport extends FreeViewport {
private syncGrid(): void { private syncGrid(): void {
this.grid.clear(); this.grid.clear();
const step = GRID_STEP * this.worldScale(); const step = SCENE_GRID_STEP * this.worldScale();
if (step < MIN_GRID_STEP) return; if (step < MIN_GRID_STEP) return;
const origin = worldToScreen(this.currentView(), 0, 0); const origin = worldToScreen(this.currentView(), 0, 0);
drawGrid(this.grid, { drawGrid(this.grid, {
+2
View File
@@ -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. */ /** Below this on-screen spacing a grid is noise rather than orientation, so it is dropped. */
export const MIN_GRID_STEP = 12; 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. */ /** What the scene view lends the tools that work inside it. */
export interface SceneViewHost { export interface SceneViewHost {
+250
View File
@@ -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<C extends Command>(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 });
});
});
+131
View File
@@ -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');
}
+2
View File
@@ -53,6 +53,7 @@ import { vectorArt } from './47-vectorart.mjs';
import { pixelArt } from './48-pixelart.mjs'; import { pixelArt } from './48-pixelart.mjs';
import { users } from './49-users.mjs'; import { users } from './49-users.mjs';
import { snapshots } from './50-snapshots.mjs'; import { snapshots } from './50-snapshots.mjs';
import { clipboard } from './51-clipboard.mjs';
export const STEPS = [ export const STEPS = [
hierarchy, hierarchy,
@@ -105,4 +106,5 @@ export const STEPS = [
pixelArt, pixelArt,
users, users,
snapshots, snapshots,
clipboard,
]; ];