feat: Display keyboard shortcuts in context menus (#9785)

* feat: Display keyboard shortcuts in context menus

* refactor: Add mapping to keyboard shortcut to context menu item interfaces

* chore: Add comment

* refactor: Don't match shortcuts and menus based on ID
This commit is contained in:
Aaron Dodson
2026-05-05 12:56:22 -07:00
committed by GitHub
parent 1662e8b120
commit b5a54b9e54
5 changed files with 173 additions and 92 deletions
+48 -1
View File
@@ -11,6 +11,7 @@ import type {BlockSvg} from './block_svg.js';
import * as browserEvents from './browser_events.js';
import {config} from './config.js';
import type {
ActionContextMenuOption,
ContextMenuOption,
LegacyContextMenuOption,
} from './contextmenu_registry.js';
@@ -25,6 +26,7 @@ import * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import {Rect} from './utils/rect.js';
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
import * as svgMath from './utils/svg_math.js';
import * as WidgetDiv from './widgetdiv.js';
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -134,7 +136,7 @@ function populate_(
continue;
}
const menuItem = new MenuItem(option.text);
const menuItem = new MenuItem(makeMenuitem(option));
menuItem.setRightToLeft(rtl);
menuItem.setRole(aria.Role.MENUITEM);
menu.addChild(menuItem);
@@ -302,3 +304,48 @@ export function callbackFactory(
export function getMenu(): Menu | null {
return menu_;
}
/**
* Creates a menu item to represent the given context menu option.
* For text-based menu options, this wraps the text in a container with its
* corresponding keyboard shortcut, if any. HTML-based menu options are displayed
* as-is.
*
* @param option The context menu option to generate a menu item for.
* @returns A `MenuItem` representing the given context menu option.
*/
function makeMenuitem(
option: ActionContextMenuOption | LegacyContextMenuOption,
) {
const text = option.text;
if (text && !(text instanceof HTMLElement)) {
const container = document.createElement('div');
container.className = 'blocklyShortcutContainer';
const label = document.createElement('span');
label.textContent = text;
const shortcut = document.createElement('span');
shortcut.className = 'blocklyShortcut';
shortcut.textContent = ` ${getKeyboardShortcut(option)}`;
container.appendChild(label);
container.appendChild(shortcut);
return container;
}
return option.text;
}
/**
* Returns a textual representation of the keyboard shortcut for the given
* context menu item, if any.
*
* @param option The context menu item to retrieve a keyboard shortcut for.
* @returns A textual representation of the keyboard shortcut registered under
* the name stored in the menu option's `associatedKeyboardShortcut` field,
* if any.
*/
function getKeyboardShortcut(
option: ContextMenuOption | LegacyContextMenuOption,
): string {
if (!('id' in option) || !option.associatedKeyboardShortcut) return '';
return getShortcutKeysShort(option.associatedKeyboardShortcut);
}
@@ -52,6 +52,7 @@ export function registerUndo() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'undoWorkspace',
weight: 1,
associatedKeyboardShortcut: 'undo',
};
ContextMenuRegistry.registry.register(undoOption);
}
@@ -76,6 +77,7 @@ export function registerRedo() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'redoWorkspace',
weight: 2,
associatedKeyboardShortcut: 'redo',
};
ContextMenuRegistry.registry.register(redoOption);
}
@@ -103,6 +105,7 @@ export function registerCleanup() {
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
id: 'cleanWorkspace',
weight: 3,
associatedKeyboardShortcut: 'cleanup',
};
ContextMenuRegistry.registry.register(cleanOption);
}
@@ -349,6 +352,7 @@ export function registerDuplicate() {
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
id: 'blockDuplicate',
weight: 1,
associatedKeyboardShortcut: 'duplicate',
};
ContextMenuRegistry.registry.register(duplicateOption);
}
@@ -547,6 +551,7 @@ export function registerDelete() {
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
id: 'blockDelete',
weight: 6,
associatedKeyboardShortcut: 'delete',
};
ContextMenuRegistry.registry.register(deleteOption);
}
@@ -596,6 +601,7 @@ export function registerCommentDelete() {
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDelete',
weight: 6,
associatedKeyboardShortcut: 'delete',
};
ContextMenuRegistry.registry.register(deleteOption);
}
@@ -616,6 +622,7 @@ export function registerCommentDuplicate() {
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
id: 'commentDuplicate',
weight: 1,
associatedKeyboardShortcut: 'duplicate',
};
ContextMenuRegistry.registry.register(duplicateOption);
}
@@ -100,6 +100,7 @@ export class ContextMenuRegistry {
| ContextMenuRegistry.SeparatorContextMenuOption
| ContextMenuRegistry.ActionContextMenuOption;
menuOption = {
id: item.id,
scope,
weight: item.weight,
};
@@ -122,6 +123,7 @@ export class ContextMenuRegistry {
text: displayText,
callback: item.callback,
enabled: precondition === 'enabled',
associatedKeyboardShortcut: item.associatedKeyboardShortcut,
};
}
@@ -188,6 +190,13 @@ export namespace ContextMenuRegistry {
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
separator?: never;
preconditionFn: (p1: Scope, menuOpenEvent: Event) => string;
/**
* Identifier used to associate this context menu item with a keyboard
* shortcut which will be displayed in the menu as a hint. Should
* correspond to the name under which a keyboard shortcut that performs the
* same action as this menu item is registered.
*/
associatedKeyboardShortcut?: string;
}
/**
@@ -208,8 +217,10 @@ export namespace ContextMenuRegistry {
* Fields common to all context menu items as used by contextmenu.ts.
*/
export interface CoreContextMenuOption {
id: string;
scope: Scope;
weight: number;
associatedKeyboardShortcut?: string;
}
/**
@@ -276,3 +287,5 @@ export type RegistryItem = ContextMenuRegistry.RegistryItem;
export type ContextMenuOption = ContextMenuRegistry.ContextMenuOption;
export type LegacyContextMenuOption =
ContextMenuRegistry.LegacyContextMenuOption;
export type ActionContextMenuOption =
ContextMenuRegistry.ActionContextMenuOption;
+13
View File
@@ -484,6 +484,19 @@ input[type=number] {
margin-right: 4px;
}
.blocklyRTL .blocklyMenuItemContent .blocklyShortcutContainer {
flex-direction: row-reverse;
}
.blocklyMenuItemContent .blocklyShortcutContainer {
width: 100%;
display: flex;
justify-content: space-between;
gap: 16px;
}
.blocklyMenuItemContent .blocklyShortcutContainer .blocklyShortcut {
color: #ccc;
}
.blocklyBlockDragSurface, .blocklyAnimationLayer {
position: absolute;
top: 0;
@@ -48,97 +48,6 @@ const shortModifierNames: Record<string, string> = {
'Alt': userAgent.APPLE ? '⌥' : Msg['ALT_KEY'],
};
/**
* Key names for common characters. These should be used with keyup/keydown
* events, since the .keyCode property on those is meant to indicate the
* _physical key_ the user held down on the keyboard. Hence the mapping uses
* only the unshifted version of each key (e.g. no '#', since that's shift+3).
* Keypress events on the other hand generate (mostly) ASCII codes since they
* correspond to *characters* the user typed.
*
* For further reference: http://unixpapa.com/js/key.html
*
* This list is not localized and therefore some of the key codes are not
* correct for non-US keyboard layouts.
*
* Partially copied from goog.events.keynames and modified to use translatable
* strings or symbols for keys.
*/
const keyNames: Record<number, string> = {
8: Msg['BACKSPACE_KEY'],
9: Msg['TAB_KEY'],
13: Msg['ENTER_KEY'],
16: Msg['SHIFT_KEY'],
17: Msg['CTRL_KEY'],
18: Msg['ALT_KEY'],
19: Msg['PAUSE_KEY'],
20: Msg['CAPS_LOCK_KEY'],
27: Msg['ESCAPE_KEY'],
32: Msg['SPACE_KEY'],
33: Msg['PAGE_UP_KEY'],
34: Msg['PAGE_DOWN_KEY'],
35: Msg['END_KEY'],
36: Msg['HOME_KEY'],
37: '←',
38: '↑',
39: '→',
40: '↓',
45: Msg['INSERT_KEY'],
46: Msg['DELETE_KEY'],
48: '0',
49: '1',
50: '2',
51: '3',
52: '4',
53: '5',
54: '6',
55: '7',
56: '8',
57: '9',
59: ';',
61: '=',
93: Msg['CONTEXT_MENU_KEY'],
96: '0',
97: '1',
98: '2',
99: '3',
100: '4',
101: '5',
102: '6',
103: '7',
104: '8',
105: '9',
106: '×',
107: '+',
109: '',
110: '.',
111: '÷',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
186: ';',
187: '=',
189: '-',
188: ',',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
221: ']',
222: "'",
224: '⌘',
};
/**
* Gets a user-facing name for a keycode.
*
@@ -150,6 +59,98 @@ function getKeyName(keyCode: number): string {
// letters a-z
return String.fromCharCode(keyCode);
}
/**
* Key names for common characters. These should be used with keyup/keydown
* events, since the .keyCode property on those is meant to indicate the
* _physical key_ the user held down on the keyboard. Hence the mapping uses
* only the unshifted version of each key (e.g. no '#', since that's shift+3).
* Keypress events on the other hand generate (mostly) ASCII codes since they
* correspond to *characters* the user typed.
*
* For further reference: http://unixpapa.com/js/key.html
*
* This list is not localized and therefore some of the key codes are not
* correct for non-US keyboard layouts.
*
* Partially copied from goog.events.keynames and modified to use translatable
* strings or symbols for keys.
*/
const keyNames: Record<number, string> = {
8: Msg['BACKSPACE_KEY'],
9: Msg['TAB_KEY'],
13: Msg['ENTER_KEY'],
16: Msg['SHIFT_KEY'],
17: Msg['CTRL_KEY'],
18: Msg['ALT_KEY'],
19: Msg['PAUSE_KEY'],
20: Msg['CAPS_LOCK_KEY'],
27: Msg['ESCAPE_KEY'],
32: Msg['SPACE_KEY'],
33: Msg['PAGE_UP_KEY'],
34: Msg['PAGE_DOWN_KEY'],
35: Msg['END_KEY'],
36: Msg['HOME_KEY'],
37: '←',
38: '↑',
39: '→',
40: '↓',
45: Msg['INSERT_KEY'],
46: Msg['DELETE_KEY'],
48: '0',
49: '1',
50: '2',
51: '3',
52: '4',
53: '5',
54: '6',
55: '7',
56: '8',
57: '9',
59: ';',
61: '=',
93: Msg['CONTEXT_MENU_KEY'],
96: '0',
97: '1',
98: '2',
99: '3',
100: '4',
101: '5',
102: '6',
103: '7',
104: '8',
105: '9',
106: '×',
107: '+',
109: '',
110: '.',
111: '÷',
112: 'F1',
113: 'F2',
114: 'F3',
115: 'F4',
116: 'F5',
117: 'F6',
118: 'F7',
119: 'F8',
120: 'F9',
121: 'F10',
122: 'F11',
123: 'F12',
186: ';',
187: '=',
189: '-',
188: ',',
190: '.',
191: '/',
192: '`',
219: '[',
220: '\\',
221: ']',
222: "'",
224: '⌘',
};
const keyName = keyNames[keyCode];
if (keyName) return keyName;
console.warn('Unknown key code: ' + keyCode);