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

+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 { 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,
];