Skip to content

Commit

Permalink
feat: Keyboard shortcut modal dialog (#75)
Browse files Browse the repository at this point in the history
* feat: Add a modal dialog to display available shortcuts.

* chore: Remove hard coded shortcut table

* feat: Added categorisation to the short dialog.

---------

Co-authored-by: Edward Jung <[email protected]>
  • Loading branch information
cpcallen and Edward Jung authored Oct 24, 2024
1 parent 3099eb8 commit 81a60ee
Show file tree
Hide file tree
Showing 6 changed files with 409 additions and 23 deletions.
3 changes: 3 additions & 0 deletions src/announcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';
import {ShortcutRegistry} from 'blockly/core';
// @ts-expect-error No types in js file
import {keyCodeArrayToString} from './keynames';
Expand All @@ -13,6 +14,8 @@ import {keyCodeArrayToString} from './keynames';
*/
export class Announcer {
outputDiv: HTMLElement | null;
modalContainer: HTMLElement | null = null;
shortcutDialog: HTMLElement | null = null;
/**
* Constructor for an Announcer.
*/
Expand Down
47 changes: 47 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,50 @@ export enum LOGGING_MSG_TYPE {
WARN = 'warn',
LOG = 'log',
}

/**
* Platform specific modifier key used in shortcuts.
*/
export enum MODIFIER_KEY {
Window = 'Ctrl',
ChromeOS = 'Ctrl',
macOS = '⌘ Command',
Linux = 'Meta',
}

/**
* Categories used to organised the shortcut dialog.
* Shortcut name should match those obtained from the Blockly shortcut register.
*/
export const SHORTCUT_CATEGORIES = {
'General': [
'escape',
'exit',
'delete',
'run_code',
'toggle_keyboard_nav',
'Announce',
'List shortcuts',
'toolbox',
'disconnect',
],
'Editing': ['cut', 'copy', 'paste', 'undo', 'redo', 'mark', 'insert'],
'Code navigation': [
'previous',
'next',
'in',
'out',
'Context in',
'Context out',
'Go to previous sibling',
'Go to next sibling',
'Jump to root of current stack',
],
'Workspace navigation': [
'workspace_down',
'workspace_left',
'workspace_up',
'workspace_right',
'Clean up workspace',
],
};
46 changes: 37 additions & 9 deletions src/keynames.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,37 +120,65 @@ const keyNames = {
224: 'win',
};

const modifierKeys = ['control', 'alt', 'meta'];

/**
* Convert from a serialized key code to a string.
* Assign the appropriate class names for the key.
* Modifier keys are indicated so they can be switched to a platform specific
* key.
*/
function getKeyClassName(keyName) {
return modifierKeys.includes(keyName.toLowerCase()) ? 'key modifier' : 'key';
}

function toTitleCase(str) {
return str.charAt(0).toUpperCase() + str.substring(1).toLowerCase();
}

/**
* Convert from a serialized key code to a HTML string.
* This should be the inverse of ShortcutRegistry.createSerializedKey, but
* should also convert ascii characters to strings.
* @param {string} keycode The key code as a string of characters separated
* by the + character.
* @returns {string} A single string representing the key code.
*/
function keyCodeToString(keycode) {
let result = '';
function keyCodeToString(keycode, index) {
let result = `<span class="shortcut-combo shortcut-combo-${index}">`;
const pieces = keycode.split('+');

let piece = pieces[0];
let strrep = keyNames[piece] ?? piece;
result += strrep;

for (let i = 1; i < pieces.length; i++) {
for (let i = 0; i < pieces.length; i++) {
piece = pieces[i];
strrep = keyNames[piece] ?? piece;
result += `+${strrep}`;
const className = getKeyClassName(strrep);

if (i === pieces.length - 1 && i !== 0) {
strrep = strrep.toUpperCase();
} else {
strrep = toTitleCase(strrep);
}

if (i > 0) {
result += '+';
}
result += `<span class="${className}">${strrep}</span>`;
}
result += '</span>';
return result;
}

/**
* Convert an array of key codes into a comma-separated list of strings
* @param {Array<string>} keycodeArr The array of key codes to convert.
* @returns {string} The input array as a comma-separated list of
* human-readable strings.
* human-readable strings wrapped in HTML.
*/
export function keyCodeArrayToString(keycodeArr) {
const stringified = keycodeArr.map((keycode) => keyCodeToString(keycode));
return stringified.join(', ');
const stringified = keycodeArr.map((keycode, index) =>
keyCodeToString(keycode, index),
);
return stringified.join('<span class="separator">/</span>');
}
35 changes: 21 additions & 14 deletions src/navigation_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as Constants from './constants';
import {Navigation} from './navigation';
import {Announcer} from './announcer';
import {LineCursor} from './line_cursor';
import {ShortcutDialog} from './shortcut_dialog';

const KeyCodes = BlocklyUtils.KeyCodes;
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
Expand All @@ -44,6 +45,7 @@ export class NavigationController {
copyWorkspace: WorkspaceSvg | null = null;
navigation: Navigation = new Navigation();
announcer: Announcer = new Announcer();
shortcutDialog: ShortcutDialog = new ShortcutDialog();

isAutoNavigationEnabled: boolean = false;
hasNavigationFocus: boolean = false;
Expand Down Expand Up @@ -220,7 +222,7 @@ export class NavigationController {
[name: string]: ShortcutRegistry.KeyboardShortcut;
} = {
/** Go to the previous location. */
previous: {
previous: {
name: Constants.SHORTCUT_NAMES.PREVIOUS,
preconditionFn: (workspace) => workspace.keyboardAccessibilityMode,
callback: (workspace, _, shortcut) => {
Expand Down Expand Up @@ -367,7 +369,7 @@ export class NavigationController {
insert: {
name: Constants.SHORTCUT_NAMES.INSERT,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
switch (this.navigation.getState(workspace)) {
case Constants.STATE.WORKSPACE:
Expand Down Expand Up @@ -442,7 +444,7 @@ export class NavigationController {
disconnect: {
name: Constants.SHORTCUT_NAMES.DISCONNECT,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
switch (this.navigation.getState(workspace)) {
case Constants.STATE.WORKSPACE:
Expand All @@ -459,7 +461,7 @@ export class NavigationController {
focusToolbox: {
name: Constants.SHORTCUT_NAMES.TOOLBOX,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
switch (this.navigation.getState(workspace)) {
case Constants.STATE.WORKSPACE:
Expand Down Expand Up @@ -507,7 +509,7 @@ export class NavigationController {
wsMoveLeft: {
name: Constants.SHORTCUT_NAMES.MOVE_WS_CURSOR_LEFT,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
return this.navigation.moveWSCursor(workspace, -1, 0);
},
Expand All @@ -518,7 +520,7 @@ export class NavigationController {
wsMoveRight: {
name: Constants.SHORTCUT_NAMES.MOVE_WS_CURSOR_RIGHT,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
return this.navigation.moveWSCursor(workspace, 1, 0);
},
Expand All @@ -529,7 +531,7 @@ export class NavigationController {
wsMoveUp: {
name: Constants.SHORTCUT_NAMES.MOVE_WS_CURSOR_UP,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
return this.navigation.moveWSCursor(workspace, 0, -1);
},
Expand All @@ -540,7 +542,7 @@ export class NavigationController {
wsMoveDown: {
name: Constants.SHORTCUT_NAMES.MOVE_WS_CURSOR_DOWN,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
workspace.keyboardAccessibilityMode && !workspace.options.readOnly,
callback: (workspace) => {
return this.navigation.moveWSCursor(workspace, 0, 1);
},
Expand Down Expand Up @@ -593,9 +595,9 @@ export class NavigationController {
paste: {
name: Constants.SHORTCUT_NAMES.PASTE,
preconditionFn: (workspace) =>
workspace.keyboardAccessibilityMode &&
!workspace.options.readOnly &&
!Blockly.Gesture.inProgress(),
workspace.keyboardAccessibilityMode &&
!workspace.options.readOnly &&
!Blockly.Gesture.inProgress(),
callback: () => {
if (!this.copyData || !this.copyWorkspace) return false;
return this.navigation.paste(this.copyData, this.copyWorkspace);
Expand Down Expand Up @@ -688,11 +690,11 @@ export class NavigationController {
allowCollision: true,
},

/** List all current shortcuts in the announcer area. */
/** List all of the currently registered shortcuts. */
announceShortcuts: {
name: Constants.SHORTCUT_NAMES.LIST_SHORTCUTS,
callback: (workspace) => {
this.announcer.listShortcuts();
this.shortcutDialog.toggle();
return true;
},
keyCodes: [KeyCodes.SLASH],
Expand Down Expand Up @@ -845,7 +847,7 @@ export class NavigationController {
},
keyCodes: [
createSerializedKey(KeyCodes.TAB, [KeyCodes.SHIFT]),
KeyCodes.TAB
KeyCodes.TAB,
],
},
};
Expand All @@ -859,6 +861,11 @@ export class NavigationController {
for (const shortcut of Object.values(this.shortcuts)) {
ShortcutRegistry.registry.register(shortcut);
}

// Initalise the shortcut modal with available shortcuts. Needs
// to be done separately rather at construction, as many shortcuts
// are not registered at that point.
this.shortcutDialog.createModalContent();
}

/**
Expand Down
Loading

0 comments on commit 81a60ee

Please sign in to comment.