Skip to content

Commit c0489b4

Browse files
authored
feat: add copy api and paste into correct workspace (#9215)
* feat: add copy api and paste into correct workspace * fix: dont paste into unrendered workspaces * fix: paste precondition and add test
1 parent 89af298 commit c0489b4

File tree

4 files changed

+181
-48
lines changed

4 files changed

+181
-48
lines changed

core/clipboard.ts

Lines changed: 110 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {BlockCopyData, BlockPaster} from './clipboard/block_paster.js';
1010
import * as registry from './clipboard/registry.js';
1111
import type {ICopyData, ICopyable} from './interfaces/i_copyable.js';
12+
import {isSelectable} from './interfaces/i_selectable.js';
1213
import * as globalRegistry from './registry.js';
1314
import {Coordinate} from './utils/coordinate.js';
1415
import {WorkspaceSvg} from './workspace_svg.js';
@@ -18,18 +19,119 @@ let stashedCopyData: ICopyData | null = null;
1819

1920
let stashedWorkspace: WorkspaceSvg | null = null;
2021

22+
let stashedCoordinates: Coordinate | undefined = undefined;
23+
2124
/**
22-
* Private version of copy for stubbing in tests.
25+
* Copy a copyable item, and record its data and the workspace it was
26+
* copied from.
27+
*
28+
* This function does not perform any checks to ensure the copy
29+
* should be allowed, e.g. to ensure the block is deletable. Such
30+
* checks should be done before calling this function.
31+
*
32+
* Note that if the copyable item is not an `ISelectable` or its
33+
* `workspace` property is not a `WorkspaceSvg`, the copy will be
34+
* successful, but there will be no saved workspace data. This will
35+
* impact the ability to paste the data unless you explictily pass
36+
* a workspace into the paste method.
37+
*
38+
* @param toCopy item to copy.
39+
* @param location location to save as a potential paste location.
40+
* @returns the copied data if copy was successful, otherwise null.
2341
*/
24-
function copyInternal<T extends ICopyData>(toCopy: ICopyable<T>): T | null {
42+
export function copy<T extends ICopyData>(
43+
toCopy: ICopyable<T>,
44+
location?: Coordinate,
45+
): T | null {
2546
const data = toCopy.toCopyData();
2647
stashedCopyData = data;
27-
stashedWorkspace = (toCopy as any).workspace ?? null;
48+
if (isSelectable(toCopy) && toCopy.workspace instanceof WorkspaceSvg) {
49+
stashedWorkspace = toCopy.workspace;
50+
} else {
51+
stashedWorkspace = null;
52+
}
53+
54+
stashedCoordinates = location;
2855
return data;
2956
}
3057

3158
/**
32-
* Paste a pasteable element into the workspace.
59+
* Gets the copy data for the last item copied. This is useful if you
60+
* are implementing custom copy/paste behavior. If you want the default
61+
* behavior, just use the copy and paste methods directly.
62+
*
63+
* @returns copy data for the last item copied, or null if none set.
64+
*/
65+
export function getLastCopiedData() {
66+
return stashedCopyData;
67+
}
68+
69+
/**
70+
* Sets the last copied item. You should call this method if you implement
71+
* custom copy behavior, so that other callers are working with the correct
72+
* data. This method is called automatically if you use the built-in copy
73+
* method.
74+
*
75+
* @param copyData copy data for the last item copied.
76+
*/
77+
export function setLastCopiedData(copyData: ICopyData) {
78+
stashedCopyData = copyData;
79+
}
80+
81+
/**
82+
* Gets the workspace that was last copied from. This is useful if you
83+
* are implementing custom copy/paste behavior and want to paste on the
84+
* same workspace that was copied from. If you want the default behavior,
85+
* just use the copy and paste methods directly.
86+
*
87+
* @returns workspace that was last copied from, or null if none set.
88+
*/
89+
export function getLastCopiedWorkspace() {
90+
return stashedWorkspace;
91+
}
92+
93+
/**
94+
* Sets the workspace that was last copied from. You should call this method
95+
* if you implement custom copy behavior, so that other callers are working
96+
* with the correct data. This method is called automatically if you use the
97+
* built-in copy method.
98+
*
99+
* @param workspace workspace that was last copied from.
100+
*/
101+
export function setLastCopiedWorkspace(workspace: WorkspaceSvg) {
102+
stashedWorkspace = workspace;
103+
}
104+
105+
/**
106+
* Gets the location that was last copied from. This is useful if you
107+
* are implementing custom copy/paste behavior. If you want the
108+
* default behavior, just use the copy and paste methods directly.
109+
*
110+
* @returns last saved location, or null if none set.
111+
*/
112+
export function getLastCopiedLocation() {
113+
return stashedCoordinates;
114+
}
115+
116+
/**
117+
* Sets the location that was last copied from. You should call this method
118+
* if you implement custom copy behavior, so that other callers are working
119+
* with the correct data. This method is called automatically if you use the
120+
* built-in copy method.
121+
*
122+
* @param location last saved location, which can be used to paste at.
123+
*/
124+
export function setLastCopiedLocation(location: Coordinate) {
125+
stashedCoordinates = location;
126+
}
127+
128+
/**
129+
* Paste a pasteable element into the given workspace.
130+
*
131+
* This function does not perform any checks to ensure the paste
132+
* is allowed, e.g. that the workspace is rendered or the block
133+
* is pasteable. Such checks should be done before calling this
134+
* function.
33135
*
34136
* @param copyData The data to paste into the workspace.
35137
* @param workspace The workspace to paste the data into.
@@ -43,7 +145,7 @@ export function paste<T extends ICopyData>(
43145
): ICopyable<T> | null;
44146

45147
/**
46-
* Pastes the last copied ICopyable into the workspace.
148+
* Pastes the last copied ICopyable into the last copied-from workspace.
47149
*
48150
* @returns the pasted thing if the paste was successful, null otherwise.
49151
*/
@@ -65,7 +167,7 @@ export function paste<T extends ICopyData>(
65167
): ICopyable<ICopyData> | null {
66168
if (!copyData || !workspace) {
67169
if (!stashedCopyData || !stashedWorkspace) return null;
68-
return pasteFromData(stashedCopyData, stashedWorkspace);
170+
return pasteFromData(stashedCopyData, stashedWorkspace, stashedCoordinates);
69171
}
70172
return pasteFromData(copyData, workspace, coordinate);
71173
}
@@ -85,31 +187,11 @@ function pasteFromData<T extends ICopyData>(
85187
): ICopyable<T> | null {
86188
workspace = workspace.isMutator
87189
? workspace
88-
: (workspace.getRootWorkspace() ?? workspace);
190+
: // Use the parent workspace if it exists (e.g. for pasting into flyouts)
191+
(workspace.options.parentWorkspace ?? workspace);
89192
return (globalRegistry
90193
.getObject(globalRegistry.Type.PASTER, copyData.paster, false)
91194
?.paste(copyData, workspace, coordinate) ?? null) as ICopyable<T> | null;
92195
}
93196

94-
/**
95-
* Private version of duplicate for stubbing in tests.
96-
*/
97-
function duplicateInternal<
98-
U extends ICopyData,
99-
T extends ICopyable<U> & IHasWorkspace,
100-
>(toDuplicate: T): T | null {
101-
const data = toDuplicate.toCopyData();
102-
if (!data) return null;
103-
return paste(data, toDuplicate.workspace) as T;
104-
}
105-
106-
interface IHasWorkspace {
107-
workspace: WorkspaceSvg;
108-
}
109-
110-
export const TEST_ONLY = {
111-
duplicateInternal,
112-
copyInternal,
113-
};
114-
115197
export {BlockCopyData, BlockPaster, registry};

core/shortcut_items.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as clipboard from './clipboard.js';
1111
import {RenderedWorkspaceComment} from './comments.js';
1212
import * as eventUtils from './events/utils.js';
1313
import {getFocusManager} from './focus_manager.js';
14-
import {ICopyData, isCopyable as isICopyable} from './interfaces/i_copyable.js';
14+
import {isCopyable as isICopyable} from './interfaces/i_copyable.js';
1515
import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
1616
import {isDraggable} from './interfaces/i_draggable.js';
1717
import {IFocusableNode} from './interfaces/i_focusable_node.js';
@@ -92,9 +92,6 @@ export function registerDelete() {
9292
ShortcutRegistry.registry.register(deleteShortcut);
9393
}
9494

95-
let copyData: ICopyData | null = null;
96-
let copyCoords: Coordinate | null = null;
97-
9895
/**
9996
* Determine if a focusable node can be copied.
10097
*
@@ -175,12 +172,12 @@ export function registerCopy() {
175172
if (!focused.workspace.isFlyout) {
176173
targetWorkspace.hideChaff();
177174
}
178-
copyData = focused.toCopyData();
179-
copyCoords =
175+
176+
const copyCoords =
180177
isDraggable(focused) && focused.workspace == targetWorkspace
181178
? focused.getRelativeToSurfaceXY()
182-
: null;
183-
return !!copyData;
179+
: undefined;
180+
return !!clipboard.copy(focused, copyCoords);
184181
},
185182
keyCodes: [ctrlC, metaC],
186183
};
@@ -215,10 +212,10 @@ export function registerCut() {
215212
if (!focused || !isCuttable(focused) || !isICopyable(focused)) {
216213
return false;
217214
}
218-
copyData = focused.toCopyData();
219-
copyCoords = isDraggable(focused)
215+
const copyCoords = isDraggable(focused)
220216
? focused.getRelativeToSurfaceXY()
221-
: null;
217+
: undefined;
218+
const copyData = clipboard.copy(focused, copyCoords);
222219

223220
if (focused instanceof BlockSvg) {
224221
focused.checkAndDelete();
@@ -246,23 +243,35 @@ export function registerPaste() {
246243

247244
const pasteShortcut: KeyboardShortcut = {
248245
name: names.PASTE,
249-
preconditionFn(workspace) {
246+
preconditionFn() {
247+
// Regardless of the currently focused workspace, we will only
248+
// paste into the last-copied-from workspace.
249+
const workspace = clipboard.getLastCopiedWorkspace();
250+
// If we don't know where we copied from, we don't know where to paste.
251+
// If the workspace isn't rendered (e.g. closed mutator workspace),
252+
// we can't paste into it.
253+
if (!workspace || !workspace.rendered) return false;
250254
const targetWorkspace = workspace.isFlyout
251255
? workspace.targetWorkspace
252256
: workspace;
253257
return (
254-
!!copyData &&
258+
!!clipboard.getLastCopiedData() &&
255259
!!targetWorkspace &&
256260
!targetWorkspace.isReadOnly() &&
257261
!targetWorkspace.isDragging() &&
258262
!getFocusManager().ephemeralFocusTaken()
259263
);
260264
},
261265
callback(workspace: WorkspaceSvg, e: Event) {
266+
const copyData = clipboard.getLastCopiedData();
262267
if (!copyData) return false;
263-
const targetWorkspace = workspace.isFlyout
264-
? workspace.targetWorkspace
265-
: workspace;
268+
269+
const copyWorkspace = clipboard.getLastCopiedWorkspace();
270+
if (!copyWorkspace) return false;
271+
272+
const targetWorkspace = copyWorkspace.isFlyout
273+
? copyWorkspace.targetWorkspace
274+
: copyWorkspace;
266275
if (!targetWorkspace || targetWorkspace.isReadOnly()) return false;
267276

268277
if (e instanceof PointerEvent) {
@@ -278,6 +287,7 @@ export function registerPaste() {
278287
return !!clipboard.paste(copyData, targetWorkspace, mouseCoords);
279288
}
280289

290+
const copyCoords = clipboard.getLastCopiedLocation();
281291
if (!copyCoords) {
282292
// If we don't have location data about the original copyable, let the
283293
// paster determine position.

tests/mocha/clipboard_test.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ suite('Clipboard', function () {
7676
await mutatorIcon.setBubbleVisible(true);
7777
const mutatorWorkspace = mutatorIcon.getWorkspace();
7878
const elseIf = mutatorWorkspace.getBlocksByType('controls_if_elseif')[0];
79-
assert.notEqual(elseIf, undefined);
79+
assert.isDefined(elseIf);
8080
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
8181
assert.lengthOf(this.workspace.getAllBlocks(), 1);
8282
const data = elseIf.toCopyData();
@@ -85,6 +85,34 @@ suite('Clipboard', function () {
8585
assert.lengthOf(this.workspace.getAllBlocks(), 1);
8686
});
8787

88+
test('pasting into a mutator flyout pastes into the mutator workspace', async function () {
89+
const block = Blockly.serialization.blocks.append(
90+
{
91+
'type': 'controls_if',
92+
'id': 'blockId',
93+
'extraState': {
94+
'elseIfCount': 1,
95+
},
96+
},
97+
this.workspace,
98+
);
99+
const mutatorIcon = block.getIcon(Blockly.icons.IconType.MUTATOR);
100+
await mutatorIcon.setBubbleVisible(true);
101+
const mutatorWorkspace = mutatorIcon.getWorkspace();
102+
const mutatorFlyoutWorkspace = mutatorWorkspace
103+
.getFlyout()
104+
.getWorkspace();
105+
const elseIf =
106+
mutatorFlyoutWorkspace.getBlocksByType('controls_if_elseif')[0];
107+
assert.isDefined(elseIf);
108+
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 2);
109+
assert.lengthOf(this.workspace.getAllBlocks(), 1);
110+
const data = elseIf.toCopyData();
111+
Blockly.clipboard.paste(data, mutatorFlyoutWorkspace);
112+
assert.lengthOf(mutatorWorkspace.getAllBlocks(), 3);
113+
assert.lengthOf(this.workspace.getAllBlocks(), 1);
114+
});
115+
88116
suite('pasted blocks are placed in unambiguous locations', function () {
89117
test('pasted blocks are bumped to not overlap', function () {
90118
const block = Blockly.serialization.blocks.append(
@@ -139,8 +167,7 @@ suite('Clipboard', function () {
139167
});
140168

141169
suite('pasting comments', function () {
142-
// TODO: Reenable test when we readd copy-paste.
143-
test.skip('pasted comments are bumped to not overlap', function () {
170+
test('pasted comments are bumped to not overlap', function () {
144171
Blockly.Xml.domToWorkspace(
145172
Blockly.utils.xml.textToDom(
146173
'<xml><comment id="test" x=10 y=10/></xml>',
@@ -153,7 +180,7 @@ suite('Clipboard', function () {
153180
const newComment = Blockly.clipboard.paste(data, this.workspace);
154181
assert.deepEqual(
155182
newComment.getRelativeToSurfaceXY(),
156-
new Blockly.utils.Coordinate(60, 60),
183+
new Blockly.utils.Coordinate(40, 40),
157184
);
158185
});
159186
});

tests/mocha/shortcut_items_test.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import * as Blockly from '../../build/src/core/blockly.js';
8+
import {assert} from '../../node_modules/chai/chai.js';
89
import {defineStackBlock} from './test_helpers/block_definitions.js';
910
import {
1011
sharedTestSetup,
@@ -399,6 +400,19 @@ suite('Keyboard Shortcut Items', function () {
399400
});
400401
});
401402

403+
suite('Paste', function () {
404+
test('Disabled when nothing has been copied', function () {
405+
const pasteShortcut =
406+
Blockly.ShortcutRegistry.registry.getRegistry()[
407+
Blockly.ShortcutItems.names.PASTE
408+
];
409+
Blockly.clipboard.setLastCopiedData(undefined);
410+
411+
const isPasteEnabled = pasteShortcut.preconditionFn();
412+
assert.isFalse(isPasteEnabled);
413+
});
414+
});
415+
402416
suite('Undo', function () {
403417
setup(function () {
404418
this.undoSpy = sinon.spy(this.workspace, 'undo');

0 commit comments

Comments
 (0)