Skip to content

Commit 76388be

Browse files
authored
fix: Make cut/copy/paste work as expected (#556)
* Cleanup and fixes to cut/copy/paste * Add comments
1 parent c9465bb commit 76388be

File tree

1 file changed

+80
-121
lines changed

1 file changed

+80
-121
lines changed

src/actions/clipboard.ts

Lines changed: 80 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,15 @@
77
import {
88
ContextMenuRegistry,
99
ShortcutRegistry,
10-
ICopyData,
1110
isCopyable,
12-
isDeletable,
13-
isDraggable,
1411
Msg,
1512
ShortcutItems,
16-
Flyout,
17-
getMainWorkspace,
13+
WorkspaceSvg,
1814
} from 'blockly';
1915
import * as Constants from '../constants';
20-
import {WorkspaceSvg} from 'blockly';
2116
import {Navigation} from '../navigation';
2217
import {getShortActionShortcut} from '../shortcut_formatting';
2318
import {clearPasteHints, showCopiedHint, showCutHint} from '../hints';
24-
import {IFocusableNode} from 'blockly/core';
2519

2620
/**
2721
* Weight for the first of these three items in the context menu.
@@ -31,29 +25,18 @@ import {IFocusableNode} from 'blockly/core';
3125
*/
3226
const BASE_WEIGHT = 12;
3327

34-
/** Type of the callback function for keyboard shortcuts. */
35-
type ShortcutCallback = (
36-
workspace: WorkspaceSvg,
37-
e: Event,
38-
shortcut: ShortcutRegistry.KeyboardShortcut,
39-
scope: ContextMenuRegistry.Scope,
40-
) => boolean;
41-
4228
/**
4329
* Logic and state for cut/copy/paste actions as both keyboard shortcuts
4430
* and context menu items.
4531
* In the long term, this will likely merge with the clipboard code in core.
4632
*/
4733
export class Clipboard {
48-
/** Data copied by the copy or cut keyboard shortcuts. */
49-
private copyData: ICopyData | null = null;
50-
5134
/** The workspace a copy or cut keyboard shortcut happened in. */
5235
private copyWorkspace: WorkspaceSvg | null = null;
5336

54-
private oldCutCallback: ShortcutCallback | undefined;
55-
private oldCopyCallback: ShortcutCallback | undefined;
56-
private oldPasteCallback: ShortcutCallback | undefined;
37+
private oldCutShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
38+
private oldCopyShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
39+
private oldPasteShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
5740

5841
constructor(private navigation: Navigation) {}
5942

@@ -92,20 +75,18 @@ export class Clipboard {
9275
* Identical to the one in core but adds a toast after successful cut.
9376
*/
9477
private registerCutShortcut() {
95-
const oldCutShortcut =
78+
this.oldCutShortcut =
9679
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.CUT];
97-
if (!oldCutShortcut)
80+
if (!this.oldCutShortcut)
9881
throw new Error('No cut keyboard shortcut registered initially');
9982

100-
this.oldCutCallback = oldCutShortcut.callback;
101-
10283
const cutShortcut: ShortcutRegistry.KeyboardShortcut = {
10384
name: Constants.SHORTCUT_NAMES.CUT,
104-
preconditionFn: oldCutShortcut.preconditionFn,
85+
preconditionFn: this.oldCutShortcut.preconditionFn,
10586
callback: this.cutCallback.bind(this),
10687
// The registry gives back keycodes as an object instead of an array
10788
// See https://github.com/google/blockly/issues/9008
108-
keyCodes: oldCutShortcut.keyCodes,
89+
keyCodes: this.oldCutShortcut.keyCodes,
10990
allowCollision: false,
11091
};
11192

@@ -127,7 +108,7 @@ export class Clipboard {
127108
'%1',
128109
getShortActionShortcut(Constants.SHORTCUT_NAMES.CUT),
129110
),
130-
preconditionFn: (scope) => this.cutCopyPrecondition(scope),
111+
preconditionFn: (scope) => this.cutPrecondition(scope),
131112
callback: (scope, menuOpenEvent) => {
132113
if (!isCopyable(scope.focusedNode)) return false;
133114
const ws = scope.focusedNode.workspace;
@@ -143,30 +124,67 @@ export class Clipboard {
143124
}
144125

145126
/**
146-
* Precondition for cut and copy context menus. These are similar to the
147-
* ones in core but they don't check if a gesture is in progress,
148-
* because a gesture will always be in progress if the context menu
149-
* is open.
127+
* Precondition function for the cut context menu. This wraps the core cut
128+
* precondition to support context menus.
150129
*
151-
* @param scope scope on which the menu was opened.
152-
* @returns 'enabled', 'disabled', or 'hidden' as appropriate
130+
* @param scope scope of the shortcut or context menu item
131+
* @returns 'enabled' if the node can be cut, 'disabled' otherwise.
153132
*/
154-
private cutCopyPrecondition(scope: ContextMenuRegistry.Scope): string {
133+
private cutPrecondition(scope: ContextMenuRegistry.Scope): string {
155134
const focused = scope.focusedNode;
135+
if (!focused || !isCopyable(focused)) return 'hidden';
136+
137+
const workspace = focused.workspace;
138+
if (!(workspace instanceof WorkspaceSvg)) return 'hidden';
139+
140+
if (
141+
this.oldCutShortcut?.preconditionFn &&
142+
this.oldCutShortcut.preconditionFn(workspace, scope)
143+
) {
144+
return 'enabled';
145+
}
146+
return 'disabled';
147+
}
156148

149+
/**
150+
* Precondition function for the copy context menu. This wraps the core copy
151+
* precondition to support context menus.
152+
*
153+
* @param scope scope of the shortcut or context menu item
154+
* @returns 'enabled' if the node can be copied, 'disabled' otherwise.
155+
*/
156+
private copyPrecondition(scope: ContextMenuRegistry.Scope): string {
157+
const focused = scope.focusedNode;
157158
if (!focused || !isCopyable(focused)) return 'hidden';
158159

159160
const workspace = focused.workspace;
161+
if (!(workspace instanceof WorkspaceSvg)) return 'hidden';
162+
160163
if (
161-
!workspace.isReadOnly() &&
162-
isDeletable(focused) &&
163-
focused.isDeletable() &&
164-
isDraggable(focused) &&
165-
focused.isMovable() &&
166-
!focused.workspace.isFlyout
167-
)
164+
this.oldCopyShortcut?.preconditionFn &&
165+
this.oldCopyShortcut.preconditionFn(workspace, scope)
166+
) {
168167
return 'enabled';
168+
}
169+
return 'disabled';
170+
}
169171

172+
/**
173+
* Precondition function for the paste context menu. This wraps the core
174+
* paste precondition to support context menus.
175+
*
176+
* @param scope scope of the shortcut or context menu item
177+
* @returns 'enabled' if the node can be pasted, 'disabled' otherwise.
178+
*/
179+
private pastePrecondition(scope: ContextMenuRegistry.Scope): string {
180+
if (!this.copyWorkspace) return 'disabled';
181+
182+
if (
183+
this.oldPasteShortcut?.preconditionFn &&
184+
this.oldPasteShortcut.preconditionFn(this.copyWorkspace, scope)
185+
) {
186+
return 'enabled';
187+
}
170188
return 'disabled';
171189
}
172190

@@ -189,9 +207,10 @@ export class Clipboard {
189207
scope: ContextMenuRegistry.Scope,
190208
) {
191209
const didCut =
192-
!!this.oldCutCallback &&
193-
this.oldCutCallback(workspace, e, shortcut, scope);
210+
!!this.oldCutShortcut?.callback &&
211+
this.oldCutShortcut.callback(workspace, e, shortcut, scope);
194212
if (didCut) {
213+
this.copyWorkspace = workspace;
195214
showCutHint(workspace);
196215
}
197216
return didCut;
@@ -202,20 +221,18 @@ export class Clipboard {
202221
* Identical to the one in core but pops a toast after succesful copy.
203222
*/
204223
private registerCopyShortcut() {
205-
const oldCopyShortcut =
224+
this.oldCopyShortcut =
206225
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.COPY];
207-
if (!oldCopyShortcut)
226+
if (!this.oldCopyShortcut)
208227
throw new Error('No copy keyboard shortcut registered initially');
209228

210-
this.oldCopyCallback = oldCopyShortcut.callback;
211-
212229
const copyShortcut: ShortcutRegistry.KeyboardShortcut = {
213230
name: Constants.SHORTCUT_NAMES.COPY,
214-
preconditionFn: oldCopyShortcut.preconditionFn,
231+
preconditionFn: this.oldCopyShortcut.preconditionFn,
215232
callback: this.copyCallback.bind(this),
216233
// The registry gives back keycodes as an object instead of an array
217234
// See https://github.com/google/blockly/issues/9008
218-
keyCodes: oldCopyShortcut.keyCodes,
235+
keyCodes: this.oldCopyShortcut.keyCodes,
219236
allowCollision: false,
220237
};
221238

@@ -237,7 +254,7 @@ export class Clipboard {
237254
'%1',
238255
getShortActionShortcut(Constants.SHORTCUT_NAMES.COPY),
239256
),
240-
preconditionFn: (scope) => this.cutCopyPrecondition(scope),
257+
preconditionFn: (scope) => this.copyPrecondition(scope),
241258
callback: (scope, menuOpenEvent) => {
242259
if (!isCopyable(scope.focusedNode)) return false;
243260
const ws = scope.focusedNode.workspace;
@@ -271,9 +288,10 @@ export class Clipboard {
271288
scope: ContextMenuRegistry.Scope,
272289
) {
273290
const didCopy =
274-
!!this.oldCopyCallback &&
275-
this.oldCopyCallback(workspace, e, shortcut, scope);
291+
!!this.oldCopyShortcut?.callback &&
292+
this.oldCopyShortcut.callback(workspace, e, shortcut, scope);
276293
if (didCopy) {
294+
this.copyWorkspace = workspace;
277295
showCopiedHint(workspace);
278296
}
279297
return didCopy;
@@ -284,38 +302,18 @@ export class Clipboard {
284302
* Identical to the one in core but clears any paste toasts after.
285303
*/
286304
private registerPasteShortcut() {
287-
const oldPasteShortcut =
305+
this.oldPasteShortcut =
288306
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.PASTE];
289-
if (!oldPasteShortcut)
307+
if (!this.oldPasteShortcut)
290308
throw new Error('No paste keyboard shortcut registered initially');
291309

292-
this.oldPasteCallback = oldPasteShortcut.callback;
293-
294310
const pasteShortcut: ShortcutRegistry.KeyboardShortcut = {
295311
name: Constants.SHORTCUT_NAMES.PASTE,
296-
preconditionFn: (
297-
workspace: WorkspaceSvg,
298-
scope: ContextMenuRegistry.Scope,
299-
) => {
300-
// Don't use the workspace given as we don't want to paste in the flyout, for example
301-
const pasteWorkspace = this.getPasteWorkspace(scope);
302-
if (!pasteWorkspace || pasteWorkspace.isReadOnly()) return false;
303-
return true;
304-
},
305-
callback: (
306-
workspace: WorkspaceSvg,
307-
e: Event,
308-
shortcut: ShortcutRegistry.KeyboardShortcut,
309-
scope: ContextMenuRegistry.Scope,
310-
) => {
311-
// Don't use the workspace given as we don't want to paste in the flyout, for example
312-
const pasteWorkspace = this.getPasteWorkspace(scope);
313-
if (!pasteWorkspace) return false;
314-
return this.pasteCallback(pasteWorkspace, e, shortcut, scope);
315-
},
312+
preconditionFn: this.oldPasteShortcut.preconditionFn,
313+
callback: this.pasteCallback.bind(this),
316314
// The registry gives back keycodes as an object instead of an array
317315
// See https://github.com/google/blockly/issues/9008
318-
keyCodes: oldPasteShortcut.keyCodes,
316+
keyCodes: this.oldPasteShortcut.keyCodes,
319317
allowCollision: false,
320318
};
321319

@@ -337,17 +335,9 @@ export class Clipboard {
337335
'%1',
338336
getShortActionShortcut(Constants.SHORTCUT_NAMES.PASTE),
339337
),
340-
preconditionFn: (scope: ContextMenuRegistry.Scope) => {
341-
const workspace = this.getPasteWorkspace(scope);
342-
if (!workspace) return 'hidden';
343-
344-
// Unfortunately, this will return enabled even if nothing is in the clipboard
345-
// This is because the clipboard data is not actually exposed in core
346-
// so there's no way to check
347-
return workspace.isReadOnly() ? 'disabled' : 'enabled';
348-
},
338+
preconditionFn: (scope) => this.pastePrecondition(scope),
349339
callback: (scope: ContextMenuRegistry.Scope, menuOpenEvent: Event) => {
350-
const workspace = this.getPasteWorkspace(scope);
340+
const workspace = this.copyWorkspace;
351341
if (!workspace) return;
352342
return this.pasteCallback(workspace, menuOpenEvent, undefined, scope);
353343
},
@@ -358,37 +348,6 @@ export class Clipboard {
358348
ContextMenuRegistry.registry.register(pasteAction);
359349
}
360350

361-
/**
362-
* Gets the workspace where something should be pasted.
363-
* Tries to get the workspace the focusable item is on,
364-
* or the target workspace if the focusable item is in a flyout,
365-
* or falls back to the main workspace.
366-
*
367-
* @param scope scope from the action that initiated the paste
368-
* @returns a workspace to paste into if possible, otherwise null
369-
*/
370-
private getPasteWorkspace(scope: ContextMenuRegistry.Scope) {
371-
const focusTree = (scope.focusedNode as IFocusableNode).getFocusableTree();
372-
let workspace;
373-
if (focusTree instanceof WorkspaceSvg) {
374-
workspace = focusTree;
375-
} else if (focusTree instanceof Flyout) {
376-
// Seems like this case doesn't actually happen and a
377-
// (flyout) Workspace is returned instead, but it's possible
378-
workspace = focusTree.targetWorkspace;
379-
} else {
380-
// Give up and just paste in the main workspace
381-
workspace = getMainWorkspace() as WorkspaceSvg;
382-
}
383-
384-
if (!workspace) return null;
385-
// If we're trying to paste in a flyout, paste in the target workspace instead
386-
if (workspace.isFlyout)
387-
workspace = workspace.targetWorkspace as WorkspaceSvg;
388-
389-
return workspace;
390-
}
391-
392351
/**
393352
* The callback for the paste action. Uses the registered version of the paste callback
394353
* to perform the paste logic, then clears any toasts about pasting.
@@ -408,8 +367,8 @@ export class Clipboard {
408367
scope: ContextMenuRegistry.Scope,
409368
) {
410369
const didPaste =
411-
!!this.oldPasteCallback &&
412-
this.oldPasteCallback(workspace, e, shortcut, scope);
370+
!!this.oldPasteShortcut?.callback &&
371+
this.oldPasteShortcut.callback(workspace, e, shortcut, scope);
413372

414373
// Clear the paste hints regardless of whether something was pasted
415374
// Some implementations of paste are async and we should clear the hint

0 commit comments

Comments
 (0)