Skip to content

Commit 053faf2

Browse files
feat: Share shortcut formatting between help/menu/messages (#408)
* Share shortcut formatting between help/menu/messages Update "toast" message on enter to reference help which we think is a better option based on user testing. Partly motivated by wanting to rebind list_shortcuts in MakeCode and have the correct shortcut be shown by the toast/dialog. * fix: retain the short format for menus * chore: rename shortcut formatting functions
1 parent 09e5d0e commit 053faf2

File tree

5 files changed

+134
-122
lines changed

5 files changed

+134
-122
lines changed

src/actions/clipboard.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import * as Constants from '../constants';
1818
import type {BlockSvg, WorkspaceSvg} from 'blockly';
1919
import {Navigation} from '../navigation';
2020
import {ScopeWithConnection} from './action_menu';
21+
import {getShortActionShortcut} from '../shortcut_formatting';
2122

2223
const KeyCodes = blocklyUtils.KeyCodes;
2324
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
@@ -100,7 +101,7 @@ export class Clipboard {
100101
*/
101102
private registerCutContextMenuAction() {
102103
const cutAction: ContextMenuRegistry.RegistryItem = {
103-
displayText: (scope) => `Cut (${this.getPlatformPrefix()}X)`,
104+
displayText: (scope) => `Cut (${getShortActionShortcut('cut')})`,
104105
preconditionFn: (scope) => {
105106
const ws = scope.block?.workspace;
106107
if (!ws) return 'hidden';
@@ -195,7 +196,7 @@ export class Clipboard {
195196
*/
196197
private registerCopyContextMenuAction() {
197198
const copyAction: ContextMenuRegistry.RegistryItem = {
198-
displayText: (scope) => `Copy (${this.getPlatformPrefix()}C)`,
199+
displayText: (scope) => `Copy (${getShortActionShortcut('copy')})`,
199200
preconditionFn: (scope) => {
200201
const ws = scope.block?.workspace;
201202
if (!ws) return 'hidden';
@@ -304,7 +305,7 @@ export class Clipboard {
304305
*/
305306
private registerPasteContextMenuAction() {
306307
const pasteAction: ContextMenuRegistry.RegistryItem = {
307-
displayText: (scope) => `Paste (${this.getPlatformPrefix()}V)`,
308+
displayText: (scope) => `Paste (${getShortActionShortcut('paste')})`,
308309
preconditionFn: (scope: ScopeWithConnection) => {
309310
const block = scope.block ?? scope.connection?.getSourceBlock();
310311
const ws = block?.workspace as WorkspaceSvg | null;
@@ -372,16 +373,4 @@ export class Clipboard {
372373
Events.setGroup(false);
373374
return false;
374375
}
375-
376-
/**
377-
* Check the platform and return a prefix for the keyboard shortcut.
378-
* TODO: https://github.com/google/blockly-keyboard-experimentation/issues/155
379-
* This will eventually be the responsibility of the action code ib
380-
* Blockly core.
381-
*
382-
* @returns A platform-appropriate string for the meta key.
383-
*/
384-
private getPlatformPrefix() {
385-
return navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl + ';
386-
}
387376
}

src/actions/enter.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222

2323
import * as Constants from '../constants';
2424
import type {Navigation} from '../navigation';
25+
import {getShortActionShortcut} from '../shortcut_formatting';
2526
import {Mover} from './mover';
2627

2728
const KeyCodes = BlocklyUtils.KeyCodes;
@@ -103,14 +104,9 @@ export class EnterAction {
103104
} else if (nodeType === ASTNode.types.BLOCK) {
104105
const block = curNode.getLocation() as Block;
105106
if (!this.tryShowFullBlockFieldEditor(block)) {
106-
const metaKey = navigator.platform.startsWith('Mac') ? 'Cmd' : 'Ctrl';
107-
const canMoveInHint = `Press right arrow to move in or ${metaKey} + Enter for more options`;
108-
const genericHint = `Press ${metaKey} + Enter for options`;
109-
const hint =
110-
curNode.in()?.getSourceBlock() === block
111-
? canMoveInHint
112-
: genericHint;
113-
dialog.alert(hint);
107+
const shortcut = getShortActionShortcut('list_shortcuts');
108+
const message = `Press ${shortcut} for help on keyboard controls`;
109+
dialog.alert(message);
114110
}
115111
} else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) {
116112
this.navigation.openToolboxOrFlyout(workspace);

src/keynames.ts

Lines changed: 1 addition & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
*
2020
* Copied from goog.events.keynames
2121
*/
22-
const keyNames: Record<string, string> = {
22+
export const keyNames: Record<string, string> = {
2323
/* eslint-disable @typescript-eslint/naming-convention */
2424
8: 'backspace',
2525
9: 'tab',
@@ -121,70 +121,3 @@ const keyNames: Record<string, string> = {
121121
224: 'win',
122122
/* eslint-enable @typescript-eslint/naming-convention */
123123
};
124-
125-
const modifierKeys = ['control', 'alt', 'meta'];
126-
127-
/**
128-
* Assign the appropriate class names for the key.
129-
* Modifier keys are indicated so they can be switched to a platform specific
130-
* key.
131-
*
132-
* @param keyName The key name.
133-
*/
134-
function getKeyClassName(keyName: string) {
135-
return modifierKeys.includes(keyName.toLowerCase()) ? 'key modifier' : 'key';
136-
}
137-
138-
/**
139-
* Naive title case conversion. Uppercases first and lowercases remainder.
140-
*
141-
* @param str String.
142-
* @returns The string in title case.
143-
*/
144-
export function toTitleCase(str: string) {
145-
return str.charAt(0).toUpperCase() + str.substring(1).toLowerCase();
146-
}
147-
148-
/**
149-
* Convert from a serialized key code to a HTML string.
150-
* This should be the inverse of ShortcutRegistry.createSerializedKey, but
151-
* should also convert ascii characters to strings.
152-
*
153-
* @param keycode The key code as a string of characters separated
154-
* by the + character.
155-
* @param index Which key code this is in sequence.
156-
* @returns A single string representing the key code.
157-
*/
158-
function keyCodeToString(keycode: string, index: number) {
159-
let result = `<span class="shortcut-combo shortcut-combo-${index}">`;
160-
const pieces = keycode.split('+');
161-
162-
let piece = pieces[0];
163-
let strrep = keyNames[piece] ?? piece;
164-
165-
for (let i = 0; i < pieces.length; i++) {
166-
piece = pieces[i];
167-
strrep = keyNames[piece] ?? piece;
168-
const className = getKeyClassName(strrep);
169-
if (i > 0) {
170-
result += '+';
171-
}
172-
result += `<span class="${className}">${toTitleCase(strrep)}</span>`;
173-
}
174-
result += '</span>';
175-
return result;
176-
}
177-
178-
/**
179-
* Convert an array of key codes into a comma-separated list of strings.
180-
*
181-
* @param keycodeArr The array of key codes to convert.
182-
* @returns The input array as a comma-separated list of
183-
* human-readable strings wrapped in HTML.
184-
*/
185-
export function keyCodeArrayToString(keycodeArr: string[]): string {
186-
const stringified = keycodeArr.map((keycode, index) =>
187-
keyCodeToString(keycode, index),
188-
);
189-
return stringified.join('<span class="separator">/</span>');
190-
}

src/shortcut_dialog.ts

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import * as Blockly from 'blockly/core';
88
import * as Constants from './constants';
99
import {ShortcutRegistry} from 'blockly/core';
10-
import {keyCodeArrayToString, toTitleCase} from './keynames';
10+
import {
11+
getLongActionShortcutsAsKeys,
12+
upperCaseFirst,
13+
} from './shortcut_formatting';
1114

1215
/**
1316
* Class for handling the shortcuts dialog.
@@ -51,28 +54,14 @@ export class ShortcutDialog {
5154
/**
5255
* Update the modifier key to the user's specific platform.
5356
*/
54-
updatePlatformModifier() {
57+
updatePlatformName() {
5558
const platform = this.getPlatform();
5659
const platformEl = this.outputDiv
5760
? this.outputDiv.querySelector('.platform')
5861
: null;
59-
60-
// Update platform string
6162
if (platformEl) {
6263
platformEl.textContent = platform;
6364
}
64-
65-
if (this.shortcutDialog) {
66-
const modifierKeys =
67-
this.shortcutDialog.querySelectorAll('.key.modifier');
68-
69-
if (modifierKeys.length > 0 && platform) {
70-
for (const key of modifierKeys) {
71-
key.textContent =
72-
this.getPlatform() === 'macOS' ? '⌘ Command' : 'Ctrl';
73-
}
74-
}
75-
}
7665
}
7766

7867
toggle() {
@@ -93,7 +82,7 @@ export class ShortcutDialog {
9382
* @returns A title case version of the name.
9483
*/
9584
getReadableShortcutName(shortcutName: string) {
96-
return toTitleCase(shortcutName.replace(/_/gi, ' '));
85+
return upperCaseFirst(shortcutName.replace(/_/gi, ' '));
9786
}
9887

9988
/**
@@ -123,20 +112,10 @@ export class ShortcutDialog {
123112
`;
124113

125114
for (const keyboardShortcut of categoryShortcuts) {
126-
const codeArray =
127-
ShortcutRegistry.registry.getKeyCodesByShortcutName(keyboardShortcut);
128-
if (codeArray.length > 0) {
129-
// Only show the first shortcut if there are many
130-
const prettyPrinted =
131-
codeArray.length > 2
132-
? keyCodeArrayToString(codeArray.slice(0, 1))
133-
: keyCodeArrayToString(codeArray);
134-
135-
modalContents += `
115+
modalContents += `
136116
<td>${this.getReadableShortcutName(keyboardShortcut)}</td>
137-
<td>${prettyPrinted}</td>
117+
<td>${this.actionShortcutsToHTML(keyboardShortcut)}</td>
138118
</tr>`;
139-
}
140119
}
141120
modalContents += '</tr></tbody></table>';
142121
}
@@ -149,7 +128,7 @@ export class ShortcutDialog {
149128
this.modalContainer = this.outputDiv.querySelector('.modal-container');
150129
this.shortcutDialog = this.outputDiv.querySelector('.shortcut-modal');
151130
this.closeButton = this.outputDiv.querySelector('.close-modal');
152-
this.updatePlatformModifier();
131+
this.updatePlatformName();
153132
// Can we also intercept the Esc key to dismiss.
154133
if (this.closeButton) {
155134
this.closeButton.addEventListener('click', (e) => {
@@ -159,6 +138,22 @@ export class ShortcutDialog {
159138
}
160139
}
161140

141+
private actionShortcutsToHTML(action: string) {
142+
const shortcuts = getLongActionShortcutsAsKeys(action);
143+
return shortcuts.map((keys) => this.actionShortcutToHTML(keys)).join(' / ');
144+
}
145+
146+
private actionShortcutToHTML(keys: string[]) {
147+
const separator = navigator.platform.startsWith('Mac') ? '' : ' + ';
148+
return [
149+
`<span class="shortcut-combo">`,
150+
...keys.map((key, index) => {
151+
return `<span class="key">${key}</span>${index < keys.length - 1 ? separator : ''}`;
152+
}),
153+
`</span>`,
154+
].join('');
155+
}
156+
162157
/**
163158
* Registers an action to list shortcuts with the shortcut registry.
164159
*/
@@ -304,7 +299,7 @@ Blockly.Css.register(`
304299
border: 1px solid var(--key-border-color);
305300
border-radius: 8px;
306301
display: inline-block;
307-
margin: 0 8px;
302+
margin: 0 4px;
308303
min-width: 2em;
309304
padding: .3em .5em;
310305
text-align: center;

src/shortcut_formatting.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {ShortcutRegistry} from 'blockly';
2+
import {keyNames} from './keynames';
3+
4+
const isMacPlatform = navigator.platform.startsWith('Mac');
5+
6+
/**
7+
* Find the primary shortcut for this platform and return it as single string
8+
* in a short user facing format.
9+
*
10+
* @param action The action name, e.g. "cut".
11+
* @returns The formatted shortcut.
12+
*/
13+
export function getShortActionShortcut(action: string): string {
14+
const parts = getActionShortcutsAsKeys(action, shortModifierNames)[0];
15+
return parts.join(isMacPlatform ? ' ' : ' + ');
16+
}
17+
18+
/**
19+
* Find the relevant shortcuts for the given action for the current platform.
20+
* Keys are returned in a long user facing format.
21+
*
22+
* @param action The action name, e.g. "cut".
23+
* @returns The formatted shortcuts as individual keys.
24+
*/
25+
export function getLongActionShortcutsAsKeys(action: string): string[][] {
26+
return getActionShortcutsAsKeys(action, longModifierNames);
27+
}
28+
29+
const longModifierNames: Record<string, string> = {
30+
'Control': 'Ctrl',
31+
'Meta': '⌘ Command',
32+
'Alt': isMacPlatform ? '⌥ Option' : 'Alt',
33+
};
34+
35+
const shortModifierNames: Record<string, string> = {
36+
'Control': 'Ctrl',
37+
'Meta': '⌘',
38+
'Alt': isMacPlatform ? '⌥' : 'Alt',
39+
};
40+
41+
/**
42+
* Find the relevant shortcuts for the given action for the current platform.
43+
* Keys are returned in a user facing format.
44+
*
45+
* This could be considerably simpler if we only bound shortcuts relevant to the
46+
* current platform or tagged them with a platform.
47+
*
48+
* @param action The action name, e.g. "cut".
49+
* @param modifierNames The names to use for the Meta/Control/Alt modifiers.
50+
* @returns The formatted shortcuts.
51+
*/
52+
function getActionShortcutsAsKeys(
53+
action: string,
54+
modifierNames: Record<string, string>,
55+
): string[][] {
56+
const shortcuts = ShortcutRegistry.registry.getKeyCodesByShortcutName(action);
57+
// See ShortcutRegistry.createSerializedKey for the starting format.
58+
const named = shortcuts.map((shortcut) => {
59+
return shortcut
60+
.split('+')
61+
.map((maybeNumeric) => keyNames[maybeNumeric] ?? maybeNumeric)
62+
.map((k) => upperCaseFirst(modifierNames[k] ?? k));
63+
});
64+
65+
const command = modifierNames['Meta'];
66+
const option = modifierNames['Alt'];
67+
const control = modifierNames['Control'];
68+
// Needed to prefer Command to Option where we've bound Alt.
69+
named.sort((a, b) => {
70+
const aValue = a.includes(command) ? 1 : 0;
71+
const bValue = b.includes(command) ? 1 : 0;
72+
return bValue - aValue;
73+
});
74+
let currentPlatform = named.filter((shortcut) => {
75+
const isMacShortcut =
76+
shortcut.includes(command) || shortcut.includes(option);
77+
return isMacShortcut === isMacPlatform;
78+
});
79+
currentPlatform = currentPlatform.length === 0 ? named : currentPlatform;
80+
81+
// If there are modifiers return only one shortcut on the assumption they are
82+
// intended for different platforms. Otherwise assume they are alternatives.
83+
const hasModifiers = currentPlatform.some((shortcut) =>
84+
shortcut.some(
85+
(key) => command === key || option === key || control === key,
86+
),
87+
);
88+
return hasModifiers ? [currentPlatform[0]] : currentPlatform;
89+
}
90+
91+
/**
92+
* Convert the first character to uppercase.
93+
*
94+
* @param str String.
95+
* @returns The string in title case.
96+
*/
97+
export function upperCaseFirst(str: string) {
98+
return str.charAt(0).toUpperCase() + str.substring(1);
99+
}

0 commit comments

Comments
 (0)