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

+31 -1
View File
@@ -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',
+2
View File
@@ -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,