diff --git a/packages/blockly/core/contextmenu.ts b/packages/blockly/core/contextmenu.ts index 0ddc0843a..d50ea1407 100644 --- a/packages/blockly/core/contextmenu.ts +++ b/packages/blockly/core/contextmenu.ts @@ -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); +} diff --git a/packages/blockly/core/contextmenu_items.ts b/packages/blockly/core/contextmenu_items.ts index 45f6269c4..61f6608db 100644 --- a/packages/blockly/core/contextmenu_items.ts +++ b/packages/blockly/core/contextmenu_items.ts @@ -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); } diff --git a/packages/blockly/core/contextmenu_registry.ts b/packages/blockly/core/contextmenu_registry.ts index 5b84104c1..f26148a5f 100644 --- a/packages/blockly/core/contextmenu_registry.ts +++ b/packages/blockly/core/contextmenu_registry.ts @@ -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; diff --git a/packages/blockly/core/css.ts b/packages/blockly/core/css.ts index b58c96767..a5a80c34f 100644 --- a/packages/blockly/core/css.ts +++ b/packages/blockly/core/css.ts @@ -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; diff --git a/packages/blockly/core/utils/shortcut_formatting.ts b/packages/blockly/core/utils/shortcut_formatting.ts index 5130d72bf..dfdc17ff1 100644 --- a/packages/blockly/core/utils/shortcut_formatting.ts +++ b/packages/blockly/core/utils/shortcut_formatting.ts @@ -48,97 +48,6 @@ const shortModifierNames: Record = { '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 = { - 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 = { + 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);