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

+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,
});
}