Skip to content

Commit b5a54b9

Browse files
authored
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
1 parent 1662e8b commit b5a54b9

5 files changed

Lines changed: 173 additions & 92 deletions

File tree

packages/blockly/core/contextmenu.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {BlockSvg} from './block_svg.js';
1111
import * as browserEvents from './browser_events.js';
1212
import {config} from './config.js';
1313
import type {
14+
ActionContextMenuOption,
1415
ContextMenuOption,
1516
LegacyContextMenuOption,
1617
} from './contextmenu_registry.js';
@@ -25,6 +26,7 @@ import * as aria from './utils/aria.js';
2526
import {Coordinate} from './utils/coordinate.js';
2627
import * as dom from './utils/dom.js';
2728
import {Rect} from './utils/rect.js';
29+
import {getShortcutKeysShort} from './utils/shortcut_formatting.js';
2830
import * as svgMath from './utils/svg_math.js';
2931
import * as WidgetDiv from './widgetdiv.js';
3032
import type {WorkspaceSvg} from './workspace_svg.js';
@@ -134,7 +136,7 @@ function populate_(
134136
continue;
135137
}
136138

137-
const menuItem = new MenuItem(option.text);
139+
const menuItem = new MenuItem(makeMenuitem(option));
138140
menuItem.setRightToLeft(rtl);
139141
menuItem.setRole(aria.Role.MENUITEM);
140142
menu.addChild(menuItem);
@@ -302,3 +304,48 @@ export function callbackFactory(
302304
export function getMenu(): Menu | null {
303305
return menu_;
304306
}
307+
308+
/**
309+
* Creates a menu item to represent the given context menu option.
310+
* For text-based menu options, this wraps the text in a container with its
311+
* corresponding keyboard shortcut, if any. HTML-based menu options are displayed
312+
* as-is.
313+
*
314+
* @param option The context menu option to generate a menu item for.
315+
* @returns A `MenuItem` representing the given context menu option.
316+
*/
317+
function makeMenuitem(
318+
option: ActionContextMenuOption | LegacyContextMenuOption,
319+
) {
320+
const text = option.text;
321+
if (text && !(text instanceof HTMLElement)) {
322+
const container = document.createElement('div');
323+
container.className = 'blocklyShortcutContainer';
324+
const label = document.createElement('span');
325+
label.textContent = text;
326+
const shortcut = document.createElement('span');
327+
shortcut.className = 'blocklyShortcut';
328+
shortcut.textContent = ` ${getKeyboardShortcut(option)}`;
329+
container.appendChild(label);
330+
container.appendChild(shortcut);
331+
return container;
332+
}
333+
334+
return option.text;
335+
}
336+
337+
/**
338+
* Returns a textual representation of the keyboard shortcut for the given
339+
* context menu item, if any.
340+
*
341+
* @param option The context menu item to retrieve a keyboard shortcut for.
342+
* @returns A textual representation of the keyboard shortcut registered under
343+
* the name stored in the menu option's `associatedKeyboardShortcut` field,
344+
* if any.
345+
*/
346+
function getKeyboardShortcut(
347+
option: ContextMenuOption | LegacyContextMenuOption,
348+
): string {
349+
if (!('id' in option) || !option.associatedKeyboardShortcut) return '';
350+
return getShortcutKeysShort(option.associatedKeyboardShortcut);
351+
}

packages/blockly/core/contextmenu_items.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function registerUndo() {
5252
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
5353
id: 'undoWorkspace',
5454
weight: 1,
55+
associatedKeyboardShortcut: 'undo',
5556
};
5657
ContextMenuRegistry.registry.register(undoOption);
5758
}
@@ -76,6 +77,7 @@ export function registerRedo() {
7677
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
7778
id: 'redoWorkspace',
7879
weight: 2,
80+
associatedKeyboardShortcut: 'redo',
7981
};
8082
ContextMenuRegistry.registry.register(redoOption);
8183
}
@@ -103,6 +105,7 @@ export function registerCleanup() {
103105
scopeType: ContextMenuRegistry.ScopeType.WORKSPACE,
104106
id: 'cleanWorkspace',
105107
weight: 3,
108+
associatedKeyboardShortcut: 'cleanup',
106109
};
107110
ContextMenuRegistry.registry.register(cleanOption);
108111
}
@@ -349,6 +352,7 @@ export function registerDuplicate() {
349352
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
350353
id: 'blockDuplicate',
351354
weight: 1,
355+
associatedKeyboardShortcut: 'duplicate',
352356
};
353357
ContextMenuRegistry.registry.register(duplicateOption);
354358
}
@@ -547,6 +551,7 @@ export function registerDelete() {
547551
scopeType: ContextMenuRegistry.ScopeType.BLOCK,
548552
id: 'blockDelete',
549553
weight: 6,
554+
associatedKeyboardShortcut: 'delete',
550555
};
551556
ContextMenuRegistry.registry.register(deleteOption);
552557
}
@@ -596,6 +601,7 @@ export function registerCommentDelete() {
596601
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
597602
id: 'commentDelete',
598603
weight: 6,
604+
associatedKeyboardShortcut: 'delete',
599605
};
600606
ContextMenuRegistry.registry.register(deleteOption);
601607
}
@@ -616,6 +622,7 @@ export function registerCommentDuplicate() {
616622
scopeType: ContextMenuRegistry.ScopeType.COMMENT,
617623
id: 'commentDuplicate',
618624
weight: 1,
625+
associatedKeyboardShortcut: 'duplicate',
619626
};
620627
ContextMenuRegistry.registry.register(duplicateOption);
621628
}

packages/blockly/core/contextmenu_registry.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ export class ContextMenuRegistry {
100100
| ContextMenuRegistry.SeparatorContextMenuOption
101101
| ContextMenuRegistry.ActionContextMenuOption;
102102
menuOption = {
103+
id: item.id,
103104
scope,
104105
weight: item.weight,
105106
};
@@ -122,6 +123,7 @@ export class ContextMenuRegistry {
122123
text: displayText,
123124
callback: item.callback,
124125
enabled: precondition === 'enabled',
126+
associatedKeyboardShortcut: item.associatedKeyboardShortcut,
125127
};
126128
}
127129

@@ -188,6 +190,13 @@ export namespace ContextMenuRegistry {
188190
displayText: ((p1: Scope) => string | HTMLElement) | string | HTMLElement;
189191
separator?: never;
190192
preconditionFn: (p1: Scope, menuOpenEvent: Event) => string;
193+
/**
194+
* Identifier used to associate this context menu item with a keyboard
195+
* shortcut which will be displayed in the menu as a hint. Should
196+
* correspond to the name under which a keyboard shortcut that performs the
197+
* same action as this menu item is registered.
198+
*/
199+
associatedKeyboardShortcut?: string;
191200
}
192201

193202
/**
@@ -208,8 +217,10 @@ export namespace ContextMenuRegistry {
208217
* Fields common to all context menu items as used by contextmenu.ts.
209218
*/
210219
export interface CoreContextMenuOption {
220+
id: string;
211221
scope: Scope;
212222
weight: number;
223+
associatedKeyboardShortcut?: string;
213224
}
214225

215226
/**
@@ -276,3 +287,5 @@ export type RegistryItem = ContextMenuRegistry.RegistryItem;
276287
export type ContextMenuOption = ContextMenuRegistry.ContextMenuOption;
277288
export type LegacyContextMenuOption =
278289
ContextMenuRegistry.LegacyContextMenuOption;
290+
export type ActionContextMenuOption =
291+
ContextMenuRegistry.ActionContextMenuOption;

packages/blockly/core/css.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,19 @@ input[type=number] {
484484
margin-right: 4px;
485485
}
486486
487+
.blocklyRTL .blocklyMenuItemContent .blocklyShortcutContainer {
488+
flex-direction: row-reverse;
489+
}
490+
.blocklyMenuItemContent .blocklyShortcutContainer {
491+
width: 100%;
492+
display: flex;
493+
justify-content: space-between;
494+
gap: 16px;
495+
}
496+
.blocklyMenuItemContent .blocklyShortcutContainer .blocklyShortcut {
497+
color: #ccc;
498+
}
499+
487500
.blocklyBlockDragSurface, .blocklyAnimationLayer {
488501
position: absolute;
489502
top: 0;

packages/blockly/core/utils/shortcut_formatting.ts

Lines changed: 92 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -48,97 +48,6 @@ const shortModifierNames: Record<string, string> = {
4848
'Alt': userAgent.APPLE ? '⌥' : Msg['ALT_KEY'],
4949
};
5050

51-
/**
52-
* Key names for common characters. These should be used with keyup/keydown
53-
* events, since the .keyCode property on those is meant to indicate the
54-
* _physical key_ the user held down on the keyboard. Hence the mapping uses
55-
* only the unshifted version of each key (e.g. no '#', since that's shift+3).
56-
* Keypress events on the other hand generate (mostly) ASCII codes since they
57-
* correspond to *characters* the user typed.
58-
*
59-
* For further reference: http://unixpapa.com/js/key.html
60-
*
61-
* This list is not localized and therefore some of the key codes are not
62-
* correct for non-US keyboard layouts.
63-
*
64-
* Partially copied from goog.events.keynames and modified to use translatable
65-
* strings or symbols for keys.
66-
*/
67-
const keyNames: Record<number, string> = {
68-
8: Msg['BACKSPACE_KEY'],
69-
9: Msg['TAB_KEY'],
70-
13: Msg['ENTER_KEY'],
71-
16: Msg['SHIFT_KEY'],
72-
17: Msg['CTRL_KEY'],
73-
18: Msg['ALT_KEY'],
74-
19: Msg['PAUSE_KEY'],
75-
20: Msg['CAPS_LOCK_KEY'],
76-
27: Msg['ESCAPE_KEY'],
77-
32: Msg['SPACE_KEY'],
78-
33: Msg['PAGE_UP_KEY'],
79-
34: Msg['PAGE_DOWN_KEY'],
80-
35: Msg['END_KEY'],
81-
36: Msg['HOME_KEY'],
82-
37: '←',
83-
38: '↑',
84-
39: '→',
85-
40: '↓',
86-
45: Msg['INSERT_KEY'],
87-
46: Msg['DELETE_KEY'],
88-
48: '0',
89-
49: '1',
90-
50: '2',
91-
51: '3',
92-
52: '4',
93-
53: '5',
94-
54: '6',
95-
55: '7',
96-
56: '8',
97-
57: '9',
98-
59: ';',
99-
61: '=',
100-
93: Msg['CONTEXT_MENU_KEY'],
101-
96: '0',
102-
97: '1',
103-
98: '2',
104-
99: '3',
105-
100: '4',
106-
101: '5',
107-
102: '6',
108-
103: '7',
109-
104: '8',
110-
105: '9',
111-
106: '×',
112-
107: '+',
113-
109: '−',
114-
110: '.',
115-
111: '÷',
116-
112: 'F1',
117-
113: 'F2',
118-
114: 'F3',
119-
115: 'F4',
120-
116: 'F5',
121-
117: 'F6',
122-
118: 'F7',
123-
119: 'F8',
124-
120: 'F9',
125-
121: 'F10',
126-
122: 'F11',
127-
123: 'F12',
128-
186: ';',
129-
187: '=',
130-
189: '-',
131-
188: ',',
132-
190: '.',
133-
191: '/',
134-
192: '`',
135-
219: '[',
136-
220: '\\',
137-
221: ']',
138-
222: "'",
139-
224: '⌘',
140-
};
141-
14251
/**
14352
* Gets a user-facing name for a keycode.
14453
*
@@ -150,6 +59,98 @@ function getKeyName(keyCode: number): string {
15059
// letters a-z
15160
return String.fromCharCode(keyCode);
15261
}
62+
63+
/**
64+
* Key names for common characters. These should be used with keyup/keydown
65+
* events, since the .keyCode property on those is meant to indicate the
66+
* _physical key_ the user held down on the keyboard. Hence the mapping uses
67+
* only the unshifted version of each key (e.g. no '#', since that's shift+3).
68+
* Keypress events on the other hand generate (mostly) ASCII codes since they
69+
* correspond to *characters* the user typed.
70+
*
71+
* For further reference: http://unixpapa.com/js/key.html
72+
*
73+
* This list is not localized and therefore some of the key codes are not
74+
* correct for non-US keyboard layouts.
75+
*
76+
* Partially copied from goog.events.keynames and modified to use translatable
77+
* strings or symbols for keys.
78+
*/
79+
const keyNames: Record<number, string> = {
80+
8: Msg['BACKSPACE_KEY'],
81+
9: Msg['TAB_KEY'],
82+
13: Msg['ENTER_KEY'],
83+
16: Msg['SHIFT_KEY'],
84+
17: Msg['CTRL_KEY'],
85+
18: Msg['ALT_KEY'],
86+
19: Msg['PAUSE_KEY'],
87+
20: Msg['CAPS_LOCK_KEY'],
88+
27: Msg['ESCAPE_KEY'],
89+
32: Msg['SPACE_KEY'],
90+
33: Msg['PAGE_UP_KEY'],
91+
34: Msg['PAGE_DOWN_KEY'],
92+
35: Msg['END_KEY'],
93+
36: Msg['HOME_KEY'],
94+
37: '←',
95+
38: '↑',
96+
39: '→',
97+
40: '↓',
98+
45: Msg['INSERT_KEY'],
99+
46: Msg['DELETE_KEY'],
100+
48: '0',
101+
49: '1',
102+
50: '2',
103+
51: '3',
104+
52: '4',
105+
53: '5',
106+
54: '6',
107+
55: '7',
108+
56: '8',
109+
57: '9',
110+
59: ';',
111+
61: '=',
112+
93: Msg['CONTEXT_MENU_KEY'],
113+
96: '0',
114+
97: '1',
115+
98: '2',
116+
99: '3',
117+
100: '4',
118+
101: '5',
119+
102: '6',
120+
103: '7',
121+
104: '8',
122+
105: '9',
123+
106: '×',
124+
107: '+',
125+
109: '−',
126+
110: '.',
127+
111: '÷',
128+
112: 'F1',
129+
113: 'F2',
130+
114: 'F3',
131+
115: 'F4',
132+
116: 'F5',
133+
117: 'F6',
134+
118: 'F7',
135+
119: 'F8',
136+
120: 'F9',
137+
121: 'F10',
138+
122: 'F11',
139+
123: 'F12',
140+
186: ';',
141+
187: '=',
142+
189: '-',
143+
188: ',',
144+
190: '.',
145+
191: '/',
146+
192: '`',
147+
219: '[',
148+
220: '\\',
149+
221: ']',
150+
222: "'",
151+
224: '⌘',
152+
};
153+
153154
const keyName = keyNames[keyCode];
154155
if (keyName) return keyName;
155156
console.warn('Unknown key code: ' + keyCode);

0 commit comments

Comments
 (0)