Copy and paste scene objects and graph nodes (#240)
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:
1 parent
7e829f737e
commit
2b89292eb4
15 files changed
+1032
-18
No files matched your search
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user