diff --git a/README.md b/README.md index 4d221616..6e19d248 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ on this repository! Include information about how to reproduce the bug, what the bad behaviour was, and what you expected it to do. The Blockly team will triage the bug and add it to the roadmap. -## Testing in your app +## Using in your app ### Installation @@ -67,7 +67,7 @@ npm install @blockly/keyboard-navigation --save ```js import * as Blockly from 'blockly'; -import {KeyboardNavigation} from '@blockly/keyboard-experiment'; +import {KeyboardNavigation} from '@blockly/keyboard-navigation'; // Inject Blockly. const workspace = Blockly.inject('blocklyDiv', { toolbox: toolboxCategories, @@ -76,6 +76,41 @@ const workspace = Blockly.inject('blocklyDiv', { const keyboardNav = new KeyboardNavigation(workspace); ``` +### Usage with cross-tab-copy-paste plugin + +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: + +```js +import * as Blockly from 'blockly'; +import {KeyboardNavigation} from '@blockly/keyboard-navigation'; +import {CrossTabCopyPaste} from '@blockly/plugin-cross-tab-copy-paste'; + +// Inject Blockly. +const workspace = Blockly.inject('blocklyDiv', { + toolbox: toolboxCategories, +}); + +// Initialize cross-tab-copy-paste +// Must be done before keyboard-navigation +const crossTabOptions = { + // Don't use the context menu options from the ctcp plugin, + // because the keyboard-navigation plugin provides its own. + contextMenu: false, + shortcut: true, +}; +const plugin = new CrossTabCopyPaste(); +plugin.init(crossTabOptions, () => { + console.log('Use this error callback to handle TypeError while pasting'); +}); + +// Initialize keyboard-navigation. +// You must pass the `allowCrossWorkspacePaste` option in order for paste +// to appear correctly enabled/disabled in the context menu. +const keyboardNav = new KeyboardNavigation(workspace, { + allowCrossWorkspacePaste: true, +}); +``` + ## Contributing To learn more about contributing to this project, see the [contributing page](https://github.com/google/blockly-keyboard-experimentation/blob/main/CONTRIBUTING.md). diff --git a/src/actions/clipboard.ts b/src/actions/clipboard.ts index 7ef4d2fa..ad67833b 100644 --- a/src/actions/clipboard.ts +++ b/src/actions/clipboard.ts @@ -11,6 +11,8 @@ import { Msg, ShortcutItems, WorkspaceSvg, + clipboard, + isSelectable, } from 'blockly'; import * as Constants from '../constants'; import {Navigation} from '../navigation'; @@ -31,14 +33,16 @@ const BASE_WEIGHT = 12; * In the long term, this will likely merge with the clipboard code in core. */ export class Clipboard { - /** The workspace a copy or cut keyboard shortcut happened in. */ - private copyWorkspace: WorkspaceSvg | null = null; - private oldCutShortcut: ShortcutRegistry.KeyboardShortcut | undefined; private oldCopyShortcut: ShortcutRegistry.KeyboardShortcut | undefined; private oldPasteShortcut: ShortcutRegistry.KeyboardShortcut | undefined; - constructor(private navigation: Navigation) {} + constructor( + private navigation: Navigation, + private options: {allowCrossWorkspacePaste: boolean} = { + allowCrossWorkspacePaste: false, + }, + ) {} /** * Install these actions as both keyboard shortcuts and context menu items. @@ -84,8 +88,6 @@ export class Clipboard { name: Constants.SHORTCUT_NAMES.CUT, preconditionFn: this.oldCutShortcut.preconditionFn, callback: this.cutCallback.bind(this), - // The registry gives back keycodes as an object instead of an array - // See https://github.com/google/blockly/issues/9008 keyCodes: this.oldCutShortcut.keyCodes, allowCollision: false, }; @@ -143,48 +145,6 @@ export class Clipboard { return 'disabled'; } - /** - * Precondition function for the copy context menu. This wraps the core copy - * precondition to support context menus. - * - * @param scope scope of the shortcut or context menu item - * @returns 'enabled' if the node can be copied, 'disabled' otherwise. - */ - private copyPrecondition(scope: ContextMenuRegistry.Scope): string { - const focused = scope.focusedNode; - if (!focused || !isCopyable(focused)) return 'hidden'; - - const workspace = focused.workspace; - if (!(workspace instanceof WorkspaceSvg)) return 'hidden'; - - if ( - this.oldCopyShortcut?.preconditionFn && - this.oldCopyShortcut.preconditionFn(workspace, scope) - ) { - return 'enabled'; - } - return 'disabled'; - } - - /** - * Precondition function for the paste context menu. This wraps the core - * paste precondition to support context menus. - * - * @param scope scope of the shortcut or context menu item - * @returns 'enabled' if the node can be pasted, 'disabled' otherwise. - */ - private pastePrecondition(scope: ContextMenuRegistry.Scope): string { - if (!this.copyWorkspace) return 'disabled'; - - if ( - this.oldPasteShortcut?.preconditionFn && - this.oldPasteShortcut.preconditionFn(this.copyWorkspace, scope) - ) { - return 'enabled'; - } - return 'disabled'; - } - /** * The callback for the cut action. Uses the registered version of the cut callback * to perform the cut logic, then pops a toast if cut happened. @@ -207,7 +167,6 @@ export class Clipboard { !!this.oldCutShortcut?.callback && this.oldCutShortcut.callback(workspace, e, shortcut, scope); if (didCut) { - this.copyWorkspace = workspace; showCutHint(workspace); } return didCut; @@ -227,8 +186,6 @@ export class Clipboard { name: Constants.SHORTCUT_NAMES.COPY, preconditionFn: this.oldCopyShortcut.preconditionFn, callback: this.copyCallback.bind(this), - // The registry gives back keycodes as an object instead of an array - // See https://github.com/google/blockly/issues/9008 keyCodes: this.oldCopyShortcut.keyCodes, allowCollision: false, }; @@ -263,6 +220,29 @@ export class Clipboard { ContextMenuRegistry.registry.register(copyAction); } + /** + * Precondition function for the copy context menu. This wraps the core copy + * precondition to support context menus. + * + * @param scope scope of the shortcut or context menu item + * @returns 'enabled' if the node can be copied, 'disabled' otherwise. + */ + private copyPrecondition(scope: ContextMenuRegistry.Scope): string { + const focused = scope.focusedNode; + if (!focused || !isCopyable(focused)) return 'hidden'; + + const workspace = focused.workspace; + if (!(workspace instanceof WorkspaceSvg)) return 'hidden'; + + if ( + this.oldCopyShortcut?.preconditionFn && + this.oldCopyShortcut.preconditionFn(workspace, scope) + ) { + return 'enabled'; + } + return 'disabled'; + } + /** * The callback for the copy action. Uses the registered version of the copy callback * to perform the copy logic, then pops a toast if copy happened. @@ -285,9 +265,6 @@ export class Clipboard { !!this.oldCopyShortcut?.callback && this.oldCopyShortcut.callback(workspace, e, shortcut, scope); if (didCopy) { - this.copyWorkspace = workspace.isFlyout - ? workspace.targetWorkspace - : workspace; showCopiedHint(workspace); } return didCopy; @@ -307,8 +284,6 @@ export class Clipboard { name: Constants.SHORTCUT_NAMES.PASTE, preconditionFn: this.oldPasteShortcut.preconditionFn, callback: this.pasteCallback.bind(this), - // The registry gives back keycodes as an object instead of an array - // See https://github.com/google/blockly/issues/9008 keyCodes: this.oldPasteShortcut.keyCodes, allowCollision: false, }; @@ -330,8 +305,8 @@ export class Clipboard { getMenuItem(Msg['PASTE_SHORTCUT'], Constants.SHORTCUT_NAMES.PASTE), preconditionFn: (scope) => this.pastePrecondition(scope), callback: (scope: ContextMenuRegistry.Scope, menuOpenEvent: Event) => { - const workspace = this.copyWorkspace; - if (!workspace) return; + const workspace = this.getPasteWorkspace(scope); + if (!workspace) return false; return this.pasteCallback(workspace, menuOpenEvent, undefined, scope); }, id: 'blockPasteFromContextMenu', @@ -341,6 +316,59 @@ export class Clipboard { ContextMenuRegistry.registry.register(pasteAction); } + /** + * Get the workspace to paste into based on which type of thing the menu was opened on. + * + * @param scope scope of shortcut or context menu item + * @returns WorkspaceSvg to paste into or undefined + */ + private getPasteWorkspace( + scope: ContextMenuRegistry.Scope, + ): WorkspaceSvg | undefined { + let workspace; + if (scope.focusedNode instanceof WorkspaceSvg) { + workspace = scope.focusedNode; + } else if (isSelectable(scope.focusedNode)) { + workspace = scope.focusedNode.workspace; + } + + if (!workspace || !(workspace instanceof WorkspaceSvg)) return undefined; + return workspace; + } + + /** + * Precondition function for the paste context menu. This wraps the core + * paste precondition to support context menus. + * + * @param scope scope of the shortcut or context menu item + * @returns 'enabled' if the node can be pasted, 'disabled' otherwise. + */ + private pastePrecondition(scope: ContextMenuRegistry.Scope): string { + const workspace = this.getPasteWorkspace(scope); + // If we can't identify what workspace to paste into, hide. + if (!workspace) return 'hidden'; + + // Don't paste into flyouts. + if (workspace.isFlyout) return 'hidden'; + + if (!this.options.allowCrossWorkspacePaste) { + // Only paste into the same workspace that was copied from + // or the parent workspace of a flyout that was copied from. + let copiedWorkspace = clipboard.getLastCopiedWorkspace(); + if (copiedWorkspace?.isFlyout) + copiedWorkspace = copiedWorkspace.targetWorkspace; + if (copiedWorkspace !== workspace) return 'disabled'; + } + + if ( + this.oldPasteShortcut?.preconditionFn && + this.oldPasteShortcut.preconditionFn(workspace, scope) + ) { + return 'enabled'; + } + return 'disabled'; + } + /** * The callback for the paste action. Uses the registered version of the paste callback * to perform the paste logic, then clears any toasts about pasting. diff --git a/src/index.ts b/src/index.ts index 09d6c33e..840f87d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,11 +41,21 @@ export class KeyboardNavigation { * Constructs the keyboard navigation. * * @param workspace The workspace that the plugin will be added to. + * @param options Options for plugin + * @param options.allowCrossWorkspacePaste If true, will allow paste + * option to appear enabled when pasting in a different workspace + * than was copied from. Defaults to false. Set to true if using + * cross-tab-copy-paste plugin or similar. */ - constructor(workspace: Blockly.WorkspaceSvg) { + constructor( + workspace: Blockly.WorkspaceSvg, + options: {allowCrossWorkspacePaste: boolean} = { + allowCrossWorkspacePaste: false, + }, + ) { this.workspace = workspace; - this.navigationController = new NavigationController(); + this.navigationController = new NavigationController(options); this.navigationController.init(); this.navigationController.addWorkspace(workspace); this.navigationController.enable(workspace); diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts index a8153582..3c29c128 100644 --- a/src/navigation_controller.ts +++ b/src/navigation_controller.ts @@ -59,7 +59,7 @@ export class NavigationController { /** Keyboard shortcut for disconnection. */ disconnectAction: DisconnectAction = new DisconnectAction(this.navigation); - clipboard: Clipboard = new Clipboard(this.navigation); + clipboard: Clipboard; duplicateAction = new DuplicateAction(); @@ -78,6 +78,14 @@ export class NavigationController { stackNavigationAction: StackNavigationAction = new StackNavigationAction(); + constructor( + private options: {allowCrossWorkspacePaste: boolean} = { + allowCrossWorkspacePaste: false, + }, + ) { + this.clipboard = new Clipboard(this.navigation, options); + } + /** * Original Toolbox.prototype.onShortcut method, saved by * addShortcutHandlers. diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts index cf33e322..7f9b27e2 100644 --- a/test/webdriverio/test/actions_test.ts +++ b/test/webdriverio/test/actions_test.ts @@ -86,14 +86,12 @@ suite('Menus test', function () { {'disabled': true, 'text': 'Move Block M'}, {'disabled': true, 'text': 'Cut ⌘ X'}, {'text': 'Copy ⌘ C'}, - {'disabled': true, 'text': 'Paste ⌘ V'}, ] : [ {'text': 'Help'}, {'disabled': true, 'text': 'Move Block M'}, {'disabled': true, 'text': 'Cut Ctrl + X'}, {'text': 'Copy Ctrl + C'}, - {'disabled': true, 'text': 'Paste Ctrl + V'}, ], await contextMenuItems(this.browser), );