Skip to content

Commit e5a784b

Browse files
authored
feat: paste into correct workspace, allow cross-workspace pasting (#655)
* fix: paste into correct workspace * chore: remove outdated comment * feat: add option for allowing cross-tab-copy-paste * chore: put the clipboard methods into a sane order * chore: update readme * chore: format and lint * chore: fix test
1 parent dcfe7df commit e5a784b

File tree

5 files changed

+144
-65
lines changed

5 files changed

+144
-65
lines changed

README.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ on this repository! Include information about how to reproduce the bug, what
4343
the bad behaviour was, and what you expected it to do. The Blockly team will
4444
triage the bug and add it to the roadmap.
4545

46-
## Testing in your app
46+
## Using in your app
4747

4848
### Installation
4949

@@ -67,7 +67,7 @@ npm install @blockly/keyboard-navigation --save
6767

6868
```js
6969
import * as Blockly from 'blockly';
70-
import {KeyboardNavigation} from '@blockly/keyboard-experiment';
70+
import {KeyboardNavigation} from '@blockly/keyboard-navigation';
7171
// Inject Blockly.
7272
const workspace = Blockly.inject('blocklyDiv', {
7373
toolbox: toolboxCategories,
@@ -76,6 +76,41 @@ const workspace = Blockly.inject('blocklyDiv', {
7676
const keyboardNav = new KeyboardNavigation(workspace);
7777
```
7878

79+
### Usage with cross-tab-copy-paste plugin
80+
81+
This plugin adds context menu items for copying & pasting. It also adds feedback to copying & pasting as toasts that are shown to the user upon successful copy or cut. It is compatible with the `@blockly/plugin-cross-tab-copy-paste` by following these steps:
82+
83+
```js
84+
import * as Blockly from 'blockly';
85+
import {KeyboardNavigation} from '@blockly/keyboard-navigation';
86+
import {CrossTabCopyPaste} from '@blockly/plugin-cross-tab-copy-paste';
87+
88+
// Inject Blockly.
89+
const workspace = Blockly.inject('blocklyDiv', {
90+
toolbox: toolboxCategories,
91+
});
92+
93+
// Initialize cross-tab-copy-paste
94+
// Must be done before keyboard-navigation
95+
const crossTabOptions = {
96+
// Don't use the context menu options from the ctcp plugin,
97+
// because the keyboard-navigation plugin provides its own.
98+
contextMenu: false,
99+
shortcut: true,
100+
};
101+
const plugin = new CrossTabCopyPaste();
102+
plugin.init(crossTabOptions, () => {
103+
console.log('Use this error callback to handle TypeError while pasting');
104+
});
105+
106+
// Initialize keyboard-navigation.
107+
// You must pass the `allowCrossWorkspacePaste` option in order for paste
108+
// to appear correctly enabled/disabled in the context menu.
109+
const keyboardNav = new KeyboardNavigation(workspace, {
110+
allowCrossWorkspacePaste: true,
111+
});
112+
```
113+
79114
## Contributing
80115

81116
To learn more about contributing to this project, see the [contributing page](https://github.com/google/blockly-keyboard-experimentation/blob/main/CONTRIBUTING.md).

src/actions/clipboard.ts

Lines changed: 86 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
Msg,
1212
ShortcutItems,
1313
WorkspaceSvg,
14+
clipboard,
15+
isSelectable,
1416
} from 'blockly';
1517
import * as Constants from '../constants';
1618
import {Navigation} from '../navigation';
@@ -31,14 +33,16 @@ const BASE_WEIGHT = 12;
3133
* In the long term, this will likely merge with the clipboard code in core.
3234
*/
3335
export class Clipboard {
34-
/** The workspace a copy or cut keyboard shortcut happened in. */
35-
private copyWorkspace: WorkspaceSvg | null = null;
36-
3736
private oldCutShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
3837
private oldCopyShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
3938
private oldPasteShortcut: ShortcutRegistry.KeyboardShortcut | undefined;
4039

41-
constructor(private navigation: Navigation) {}
40+
constructor(
41+
private navigation: Navigation,
42+
private options: {allowCrossWorkspacePaste: boolean} = {
43+
allowCrossWorkspacePaste: false,
44+
},
45+
) {}
4246

4347
/**
4448
* Install these actions as both keyboard shortcuts and context menu items.
@@ -84,8 +88,6 @@ export class Clipboard {
8488
name: Constants.SHORTCUT_NAMES.CUT,
8589
preconditionFn: this.oldCutShortcut.preconditionFn,
8690
callback: this.cutCallback.bind(this),
87-
// The registry gives back keycodes as an object instead of an array
88-
// See https://github.com/google/blockly/issues/9008
8991
keyCodes: this.oldCutShortcut.keyCodes,
9092
allowCollision: false,
9193
};
@@ -143,48 +145,6 @@ export class Clipboard {
143145
return 'disabled';
144146
}
145147

146-
/**
147-
* Precondition function for the copy context menu. This wraps the core copy
148-
* precondition to support context menus.
149-
*
150-
* @param scope scope of the shortcut or context menu item
151-
* @returns 'enabled' if the node can be copied, 'disabled' otherwise.
152-
*/
153-
private copyPrecondition(scope: ContextMenuRegistry.Scope): string {
154-
const focused = scope.focusedNode;
155-
if (!focused || !isCopyable(focused)) return 'hidden';
156-
157-
const workspace = focused.workspace;
158-
if (!(workspace instanceof WorkspaceSvg)) return 'hidden';
159-
160-
if (
161-
this.oldCopyShortcut?.preconditionFn &&
162-
this.oldCopyShortcut.preconditionFn(workspace, scope)
163-
) {
164-
return 'enabled';
165-
}
166-
return 'disabled';
167-
}
168-
169-
/**
170-
* Precondition function for the paste context menu. This wraps the core
171-
* paste precondition to support context menus.
172-
*
173-
* @param scope scope of the shortcut or context menu item
174-
* @returns 'enabled' if the node can be pasted, 'disabled' otherwise.
175-
*/
176-
private pastePrecondition(scope: ContextMenuRegistry.Scope): string {
177-
if (!this.copyWorkspace) return 'disabled';
178-
179-
if (
180-
this.oldPasteShortcut?.preconditionFn &&
181-
this.oldPasteShortcut.preconditionFn(this.copyWorkspace, scope)
182-
) {
183-
return 'enabled';
184-
}
185-
return 'disabled';
186-
}
187-
188148
/**
189149
* The callback for the cut action. Uses the registered version of the cut callback
190150
* to perform the cut logic, then pops a toast if cut happened.
@@ -207,7 +167,6 @@ export class Clipboard {
207167
!!this.oldCutShortcut?.callback &&
208168
this.oldCutShortcut.callback(workspace, e, shortcut, scope);
209169
if (didCut) {
210-
this.copyWorkspace = workspace;
211170
showCutHint(workspace);
212171
}
213172
return didCut;
@@ -227,8 +186,6 @@ export class Clipboard {
227186
name: Constants.SHORTCUT_NAMES.COPY,
228187
preconditionFn: this.oldCopyShortcut.preconditionFn,
229188
callback: this.copyCallback.bind(this),
230-
// The registry gives back keycodes as an object instead of an array
231-
// See https://github.com/google/blockly/issues/9008
232189
keyCodes: this.oldCopyShortcut.keyCodes,
233190
allowCollision: false,
234191
};
@@ -263,6 +220,29 @@ export class Clipboard {
263220
ContextMenuRegistry.registry.register(copyAction);
264221
}
265222

223+
/**
224+
* Precondition function for the copy context menu. This wraps the core copy
225+
* precondition to support context menus.
226+
*
227+
* @param scope scope of the shortcut or context menu item
228+
* @returns 'enabled' if the node can be copied, 'disabled' otherwise.
229+
*/
230+
private copyPrecondition(scope: ContextMenuRegistry.Scope): string {
231+
const focused = scope.focusedNode;
232+
if (!focused || !isCopyable(focused)) return 'hidden';
233+
234+
const workspace = focused.workspace;
235+
if (!(workspace instanceof WorkspaceSvg)) return 'hidden';
236+
237+
if (
238+
this.oldCopyShortcut?.preconditionFn &&
239+
this.oldCopyShortcut.preconditionFn(workspace, scope)
240+
) {
241+
return 'enabled';
242+
}
243+
return 'disabled';
244+
}
245+
266246
/**
267247
* The callback for the copy action. Uses the registered version of the copy callback
268248
* to perform the copy logic, then pops a toast if copy happened.
@@ -285,9 +265,6 @@ export class Clipboard {
285265
!!this.oldCopyShortcut?.callback &&
286266
this.oldCopyShortcut.callback(workspace, e, shortcut, scope);
287267
if (didCopy) {
288-
this.copyWorkspace = workspace.isFlyout
289-
? workspace.targetWorkspace
290-
: workspace;
291268
showCopiedHint(workspace);
292269
}
293270
return didCopy;
@@ -307,8 +284,6 @@ export class Clipboard {
307284
name: Constants.SHORTCUT_NAMES.PASTE,
308285
preconditionFn: this.oldPasteShortcut.preconditionFn,
309286
callback: this.pasteCallback.bind(this),
310-
// The registry gives back keycodes as an object instead of an array
311-
// See https://github.com/google/blockly/issues/9008
312287
keyCodes: this.oldPasteShortcut.keyCodes,
313288
allowCollision: false,
314289
};
@@ -330,8 +305,8 @@ export class Clipboard {
330305
getMenuItem(Msg['PASTE_SHORTCUT'], Constants.SHORTCUT_NAMES.PASTE),
331306
preconditionFn: (scope) => this.pastePrecondition(scope),
332307
callback: (scope: ContextMenuRegistry.Scope, menuOpenEvent: Event) => {
333-
const workspace = this.copyWorkspace;
334-
if (!workspace) return;
308+
const workspace = this.getPasteWorkspace(scope);
309+
if (!workspace) return false;
335310
return this.pasteCallback(workspace, menuOpenEvent, undefined, scope);
336311
},
337312
id: 'blockPasteFromContextMenu',
@@ -341,6 +316,59 @@ export class Clipboard {
341316
ContextMenuRegistry.registry.register(pasteAction);
342317
}
343318

319+
/**
320+
* Get the workspace to paste into based on which type of thing the menu was opened on.
321+
*
322+
* @param scope scope of shortcut or context menu item
323+
* @returns WorkspaceSvg to paste into or undefined
324+
*/
325+
private getPasteWorkspace(
326+
scope: ContextMenuRegistry.Scope,
327+
): WorkspaceSvg | undefined {
328+
let workspace;
329+
if (scope.focusedNode instanceof WorkspaceSvg) {
330+
workspace = scope.focusedNode;
331+
} else if (isSelectable(scope.focusedNode)) {
332+
workspace = scope.focusedNode.workspace;
333+
}
334+
335+
if (!workspace || !(workspace instanceof WorkspaceSvg)) return undefined;
336+
return workspace;
337+
}
338+
339+
/**
340+
* Precondition function for the paste context menu. This wraps the core
341+
* paste precondition to support context menus.
342+
*
343+
* @param scope scope of the shortcut or context menu item
344+
* @returns 'enabled' if the node can be pasted, 'disabled' otherwise.
345+
*/
346+
private pastePrecondition(scope: ContextMenuRegistry.Scope): string {
347+
const workspace = this.getPasteWorkspace(scope);
348+
// If we can't identify what workspace to paste into, hide.
349+
if (!workspace) return 'hidden';
350+
351+
// Don't paste into flyouts.
352+
if (workspace.isFlyout) return 'hidden';
353+
354+
if (!this.options.allowCrossWorkspacePaste) {
355+
// Only paste into the same workspace that was copied from
356+
// or the parent workspace of a flyout that was copied from.
357+
let copiedWorkspace = clipboard.getLastCopiedWorkspace();
358+
if (copiedWorkspace?.isFlyout)
359+
copiedWorkspace = copiedWorkspace.targetWorkspace;
360+
if (copiedWorkspace !== workspace) return 'disabled';
361+
}
362+
363+
if (
364+
this.oldPasteShortcut?.preconditionFn &&
365+
this.oldPasteShortcut.preconditionFn(workspace, scope)
366+
) {
367+
return 'enabled';
368+
}
369+
return 'disabled';
370+
}
371+
344372
/**
345373
* The callback for the paste action. Uses the registered version of the paste callback
346374
* to perform the paste logic, then clears any toasts about pasting.

src/index.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,21 @@ export class KeyboardNavigation {
4141
* Constructs the keyboard navigation.
4242
*
4343
* @param workspace The workspace that the plugin will be added to.
44+
* @param options Options for plugin
45+
* @param options.allowCrossWorkspacePaste If true, will allow paste
46+
* option to appear enabled when pasting in a different workspace
47+
* than was copied from. Defaults to false. Set to true if using
48+
* cross-tab-copy-paste plugin or similar.
4449
*/
45-
constructor(workspace: Blockly.WorkspaceSvg) {
50+
constructor(
51+
workspace: Blockly.WorkspaceSvg,
52+
options: {allowCrossWorkspacePaste: boolean} = {
53+
allowCrossWorkspacePaste: false,
54+
},
55+
) {
4656
this.workspace = workspace;
4757

48-
this.navigationController = new NavigationController();
58+
this.navigationController = new NavigationController(options);
4959
this.navigationController.init();
5060
this.navigationController.addWorkspace(workspace);
5161
this.navigationController.enable(workspace);

src/navigation_controller.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export class NavigationController {
5959
/** Keyboard shortcut for disconnection. */
6060
disconnectAction: DisconnectAction = new DisconnectAction(this.navigation);
6161

62-
clipboard: Clipboard = new Clipboard(this.navigation);
62+
clipboard: Clipboard;
6363

6464
duplicateAction = new DuplicateAction();
6565

@@ -78,6 +78,14 @@ export class NavigationController {
7878

7979
stackNavigationAction: StackNavigationAction = new StackNavigationAction();
8080

81+
constructor(
82+
private options: {allowCrossWorkspacePaste: boolean} = {
83+
allowCrossWorkspacePaste: false,
84+
},
85+
) {
86+
this.clipboard = new Clipboard(this.navigation, options);
87+
}
88+
8189
/**
8290
* Original Toolbox.prototype.onShortcut method, saved by
8391
* addShortcutHandlers.

test/webdriverio/test/actions_test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,12 @@ suite('Menus test', function () {
8686
{'disabled': true, 'text': 'Move Block M'},
8787
{'disabled': true, 'text': 'Cut ⌘ X'},
8888
{'text': 'Copy ⌘ C'},
89-
{'disabled': true, 'text': 'Paste ⌘ V'},
9089
]
9190
: [
9291
{'text': 'Help'},
9392
{'disabled': true, 'text': 'Move Block M'},
9493
{'disabled': true, 'text': 'Cut Ctrl + X'},
9594
{'text': 'Copy Ctrl + C'},
96-
{'disabled': true, 'text': 'Paste Ctrl + V'},
9795
],
9896
await contextMenuItems(this.browser),
9997
);

0 commit comments

Comments
 (0)