@blockly/keyboard-navigation
Version:
A plugin for keyboard navigation.
380 lines (340 loc) • 13.3 kB
text/typescript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
ContextMenuRegistry,
ShortcutRegistry,
isCopyable,
Msg,
ShortcutItems,
WorkspaceSvg,
} from 'blockly';
import * as Constants from '../constants';
import {Navigation} from '../navigation';
import {getShortActionShortcut} from '../shortcut_formatting';
import {clearPasteHints, showCopiedHint, showCutHint} from '../hints';
/**
* Weight for the first of these three items in the context menu.
* Changing base weight will change where this group goes in the context
* menu; changing individual weights relative to base weight can change
* the order within the clipboard group.
*/
const BASE_WEIGHT = 12;
/**
* Logic and state for cut/copy/paste actions as both keyboard shortcuts
* and context menu items.
* 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) {}
/**
* Install these actions as both keyboard shortcuts and context menu items.
*/
install() {
this.registerCopyShortcut();
this.registerCopyContextMenuAction();
this.registerPasteShortcut();
this.registerPasteContextMenuAction();
this.registerCutShortcut();
this.registerCutContextMenuAction();
}
/**
* Uninstall this action as both a keyboard shortcut and a context menu item.
* N. B. This does *not* currently reinstall the original keyboard shortcuts.
* You should manually reinstall the previously registered shortcuts (either
* from core or from another plugin you may be using).
*/
uninstall() {
ContextMenuRegistry.registry.unregister('blockCutFromContextMenu');
ContextMenuRegistry.registry.unregister('blockCopyFromContextMenu');
ContextMenuRegistry.registry.unregister('blockPasteFromContextMenu');
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.CUT);
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.COPY);
ShortcutRegistry.registry.unregister(Constants.SHORTCUT_NAMES.PASTE);
}
/**
* Create and register the keyboard shortcut for the cut action.
* Identical to the one in core but adds a toast after successful cut.
*/
private registerCutShortcut() {
this.oldCutShortcut =
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.CUT];
if (!this.oldCutShortcut)
throw new Error('No cut keyboard shortcut registered initially');
const cutShortcut: ShortcutRegistry.KeyboardShortcut = {
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,
};
ShortcutRegistry.registry.unregister(ShortcutItems.names.CUT);
ShortcutRegistry.registry.register(cutShortcut);
}
/**
* Register the cut block action as a context menu item.
* The context menu uses its own preconditionFn (that doesn't check
* if a gesture is in progress, because one always is in the context
* menu). It calls the cut callback that is shared between keyboard
* and context menu.
*/
private registerCutContextMenuAction() {
const cutAction: ContextMenuRegistry.RegistryItem = {
displayText: (scope) =>
Msg['CUT_SHORTCUT'].replace(
'%1',
getShortActionShortcut(Constants.SHORTCUT_NAMES.CUT),
),
preconditionFn: (scope) => this.cutPrecondition(scope),
callback: (scope, menuOpenEvent) => {
if (!isCopyable(scope.focusedNode)) return false;
const ws = scope.focusedNode.workspace;
if (!(ws instanceof WorkspaceSvg)) return false;
return this.cutCallback(ws, menuOpenEvent, undefined, scope);
},
id: 'blockCutFromContextMenu',
weight: BASE_WEIGHT,
};
ContextMenuRegistry.registry.register(cutAction);
}
/**
* Precondition function for the cut context menu. This wraps the core cut
* precondition to support context menus.
*
* @param scope scope of the shortcut or context menu item
* @returns 'enabled' if the node can be cut, 'disabled' otherwise.
*/
private cutPrecondition(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.oldCutShortcut?.preconditionFn &&
this.oldCutShortcut.preconditionFn(workspace, scope)
) {
return 'enabled';
}
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.
*
* @param workspace Workspace where shortcut happened.
* @param e menu open event or keyboard event
* @param shortcut keyboard shortcut or undefined for context menus
* @param scope scope of the shortcut or context menu item
* @returns true if a cut happened, false otherwise
*/
private cutCallback(
workspace: WorkspaceSvg,
e: Event,
shortcut: ShortcutRegistry.KeyboardShortcut = {
name: Constants.SHORTCUT_NAMES.CUT,
},
scope: ContextMenuRegistry.Scope,
) {
const didCut =
!!this.oldCutShortcut?.callback &&
this.oldCutShortcut.callback(workspace, e, shortcut, scope);
if (didCut) {
this.copyWorkspace = workspace;
showCutHint(workspace);
}
return didCut;
}
/**
* Create and register the keyboard shortcut for the copy action.
* Identical to the one in core but pops a toast after succesful copy.
*/
private registerCopyShortcut() {
this.oldCopyShortcut =
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.COPY];
if (!this.oldCopyShortcut)
throw new Error('No copy keyboard shortcut registered initially');
const copyShortcut: ShortcutRegistry.KeyboardShortcut = {
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,
};
ShortcutRegistry.registry.unregister(ShortcutItems.names.COPY);
ShortcutRegistry.registry.register(copyShortcut);
}
/**
* Register the copy block action as a context menu item.
* The context menu uses its own preconditionFn (that doesn't check
* if a gesture is in progress, because one always is in the context
* menu). It calls the copy callback that is shared between keyboard
* and context menu.
*/
private registerCopyContextMenuAction() {
const copyAction: ContextMenuRegistry.RegistryItem = {
displayText: (scope) =>
Msg['COPY_SHORTCUT'].replace(
'%1',
getShortActionShortcut(Constants.SHORTCUT_NAMES.COPY),
),
preconditionFn: (scope) => this.copyPrecondition(scope),
callback: (scope, menuOpenEvent) => {
if (!isCopyable(scope.focusedNode)) return false;
const ws = scope.focusedNode.workspace;
if (!(ws instanceof WorkspaceSvg)) return false;
return this.copyCallback(ws, menuOpenEvent, undefined, scope);
},
id: 'blockCopyFromContextMenu',
weight: BASE_WEIGHT + 1,
};
ContextMenuRegistry.registry.register(copyAction);
}
/**
* 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.
*
* @param workspace Workspace where shortcut happened.
* @param e menu open event or keyboard event
* @param shortcut keyboard shortcut or undefined for context menus
* @param scope scope of the shortcut or context menu item
* @returns true if a copy happened, false otherwise
*/
private copyCallback(
workspace: WorkspaceSvg,
e: Event,
shortcut: ShortcutRegistry.KeyboardShortcut = {
name: Constants.SHORTCUT_NAMES.CUT,
},
scope: ContextMenuRegistry.Scope,
) {
const didCopy =
!!this.oldCopyShortcut?.callback &&
this.oldCopyShortcut.callback(workspace, e, shortcut, scope);
if (didCopy) {
this.copyWorkspace = workspace;
showCopiedHint(workspace);
}
return didCopy;
}
/**
* Create and register the keyboard shortcut for the paste action.
* Identical to the one in core but clears any paste toasts after.
*/
private registerPasteShortcut() {
this.oldPasteShortcut =
ShortcutRegistry.registry.getRegistry()[ShortcutItems.names.PASTE];
if (!this.oldPasteShortcut)
throw new Error('No paste keyboard shortcut registered initially');
const pasteShortcut: ShortcutRegistry.KeyboardShortcut = {
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,
};
ShortcutRegistry.registry.unregister(ShortcutItems.names.PASTE);
ShortcutRegistry.registry.register(pasteShortcut);
}
/**
* Register the paste block action as a context menu item.
* The context menu uses its own preconditionFn (that doesn't check
* if a gesture is in progress, because one always is in the context
* menu). It calls the paste callback that is shared between keyboard
* and context menu.
*/
private registerPasteContextMenuAction() {
const pasteAction: ContextMenuRegistry.RegistryItem = {
displayText: (scope) =>
Msg['PASTE_SHORTCUT'].replace(
'%1',
getShortActionShortcut(Constants.SHORTCUT_NAMES.PASTE),
),
preconditionFn: (scope) => this.pastePrecondition(scope),
callback: (scope: ContextMenuRegistry.Scope, menuOpenEvent: Event) => {
const workspace = this.copyWorkspace;
if (!workspace) return;
return this.pasteCallback(workspace, menuOpenEvent, undefined, scope);
},
id: 'blockPasteFromContextMenu',
weight: BASE_WEIGHT + 2,
};
ContextMenuRegistry.registry.register(pasteAction);
}
/**
* 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.
*
* @param workspace Workspace where shortcut happened.
* @param e menu open event or keyboard event
* @param shortcut keyboard shortcut or undefined for context menus
* @param scope scope of the shortcut or context menu item
* @returns true if a paste happened, false otherwise
*/
private pasteCallback(
workspace: WorkspaceSvg,
e: Event,
shortcut: ShortcutRegistry.KeyboardShortcut = {
name: Constants.SHORTCUT_NAMES.CUT,
},
scope: ContextMenuRegistry.Scope,
) {
const didPaste =
!!this.oldPasteShortcut?.callback &&
this.oldPasteShortcut.callback(workspace, e, shortcut, scope);
// Clear the paste hints regardless of whether something was pasted
// Some implementations of paste are async and we should clear the hint
// once the user initiates the paste action.
clearPasteHints(workspace);
return didPaste;
}
}