Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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).
144 changes: 86 additions & 58 deletions src/actions/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
Msg,
ShortcutItems,
WorkspaceSvg,
clipboard,
isSelectable,
} from 'blockly';
import * as Constants from '../constants';
import {Navigation} from '../navigation';
Expand All @@ -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.
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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,
};
Expand Down Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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,
};
Expand All @@ -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',
Expand All @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 9 additions & 1 deletion src/navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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.
Expand Down
2 changes: 0 additions & 2 deletions test/webdriverio/test/actions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
Loading