Commit 191f857b authored by Nathan Friend's avatar Nathan Friend Committed by Brandon Labuschagne

Abstract keyboard shortcut bindings

This commit updates the way in which we bind key sequences to functions
when implementing keyboard shortcuts.

Before this commit, developers would directly bind a key sequence to a
handler:

```js
Mousetrap.bind('p b', handler);
```

After this commit, developers will instead define a new "command" in
`~/behaviors/shortcuts/keybindings.js` and bind to the result of
`keysFor(command)`:

```js
import { keysFor, TOGGLE_PERFORMANCE_BAR }
from '~/behaviors/shortcuts/keybindings.js';

Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
```

`keybindings.js` handles returning the appropriate key sequence for
the command (or it returns `[]` if the command is disabled).
parent 2beb3881
import { flatten } from 'lodash';
import { s__ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import { shouldDisableShortcuts } from './shortcuts_toggle';
export const LOCAL_STORAGE_KEY = 'gl-keyboard-shortcuts-customizations';
let parsedCustomizations = {};
const localStorageIsSafe = AccessorUtilities.isLocalStorageAccessSafe();
if (localStorageIsSafe) {
try {
parsedCustomizations = JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) || '{}');
} catch (e) {
/* do nothing */
}
}
/**
* A map of command => keys of all keyboard shortcuts
* that have been customized by the user.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
*
* @type { Object.<string, string[]> }
*/
export const customizations = parsedCustomizations;
// All available commands
export const TOGGLE_PERFORMANCE_BAR = 'globalShortcuts.togglePerformanceBar';
/** All keybindings, grouped and ordered with descriptions */
export const keybindingGroups = [
{
groupId: 'globalShortcuts',
name: s__('KeyboardShortcuts|Global Shortcuts'),
keybindings: [
{
description: s__('KeyboardShortcuts|Toggle the Performance Bar'),
command: TOGGLE_PERFORMANCE_BAR,
// eslint-disable-next-line @gitlab/require-i18n-strings
defaultKeys: ['p b'],
},
],
},
]
// For each keybinding object, add a `customKeys` property populated with the
// user's custom keybindings (if the command has been customized).
// `customKeys` will be `undefined` if the command hasn't been customized.
.map(group => {
return {
...group,
keybindings: group.keybindings.map(binding => ({
...binding,
customKeys: customizations[binding.command],
})),
};
});
/**
* A simple map of command => keys. All user customizations are included in this map.
* This mapping is used to simplify `keysFor` below.
*
* @example
* { "globalShortcuts.togglePerformanceBar": ["p e r f"] }
*/
const commandToKeys = flatten(keybindingGroups.map(group => group.keybindings)).reduce(
(acc, binding) => {
acc[binding.command] = binding.customKeys || binding.defaultKeys;
return acc;
},
{},
);
/**
* Gets keyboard shortcuts associated with a command
*
* @param {string} command The command string. All command
* strings are available as imports from this file.
*
* @returns {string[]} An array of keyboard shortcut strings bound to the command
*
* @example
* import { keysFor, TOGGLE_PERFORMANCE_BAR } from '~/behaviors/shortcuts/keybindings'
*
* Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), handler);
*/
export const keysFor = command => {
if (shouldDisableShortcuts()) {
return [];
}
return commandToKeys[command];
};
......@@ -9,6 +9,7 @@ import axios from '../../lib/utils/axios_utils';
import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils';
import { keysFor, TOGGLE_PERFORMANCE_BAR } from './keybindings';
const defaultStopCallback = Mousetrap.prototype.stopCallback;
Mousetrap.prototype.stopCallback = function customStopCallback(e, element, combo) {
......@@ -70,7 +71,7 @@ export default class Shortcuts {
Mousetrap.bind('s', Shortcuts.focusSearch);
Mousetrap.bind('/', Shortcuts.focusSearch);
Mousetrap.bind('f', this.focusFilter.bind(this));
Mousetrap.bind('p b', Shortcuts.onTogglePerfBar);
Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), Shortcuts.onTogglePerfBar);
const findFileURL = document.body.dataset.findFile;
......
......@@ -76,6 +76,10 @@ How we use SVG for our [Icons and Illustrations](icons.md).
General information about frontend [dependencies](dependencies.md) and how we manage them.
## Keyboard Shortcuts
How we implement [keyboard shortcuts](keyboard_shortcuts.md) that can be customized and disabled.
## Frontend FAQ
Read the [frontend's FAQ](frontend_faq.md) for common small pieces of helpful information.
......
# Implementing keyboard shortcuts
We use [Mousetrap](https://craig.is/killing/mice) to implement keyboard
shortcuts in GitLab.
Mousetrap provides an API that allows keyboard shortcut strings (like
`mod+shift+p` or `p b`) to be bound to a JavaScript handler:
```javascript
// Don't do this; see note below
Mousetrap.bind('p b', togglePerformanceBar)
```
However, associating a hard-coded key sequence to a handler (as shown above)
prevents these keyboard shortcuts from being customized or disabled by users.
To allow keyboard shortcuts to be customized, commands are defined in
`~/behaviors/shortcuts/keybindings.js`. The `keysFor` method is responsible for
returning the correct key sequence for the provided command:
```javascript
import { keysFor, TOGGLE_PERFORMANCE_BAR } from '~/behaviors/shortcuts/keybindings'
Mousetrap.bind(keysFor(TOGGLE_PERFORMANCE_BAR), togglePerformanceBar);
```
## Shortcut customization
`keybindings.js` stores keyboard shortcut customizations as a JSON string in
`localStorage`. When `keybindings.js` is first imported, it fetches any
customizations from `localStorage` and merges these customizations into the
default set of keybindings. There is no UI to edit these customizations.
## Adding new shortcuts
Because keyboard shortcuts can be customized or disabled by end users,
developers are encouraged to build _lots_ of keyboard shortcuts into GitLab.
Shortcuts that are less likely to be used should be
[disabled](#disabling-shortcuts) by default.
To add a new shortcut, define and export a new command string in
`keybindings.js`:
```javascript
export const MAKE_COFFEE = 'foodAndBeverage.makeCoffee';
```
Next, add a new command definition under the appropriate group in the
`keybindingGroups` array:
```javascript
{
description: s__('KeyboardShortcuts|Make coffee'),
command: MAKE_COFFEE,
defaultKeys: ['mod+shift+c'],
customKeys: customizations[MAKE_COFFEE],
}
```
Finally, in the application code, import the `keysFor` function and the new
command and bind the shortcut to the handler using Mousetrap:
```javascript
import { keysFor, MAKE_COFFEE } from '~/behaviors/shortcuts/keybindings'
Mousetrap.bind(keysFor(MAKE_COFFEE), makeCoffee);
```
See the existing the command definitions in `keybindings.js` for more examples.
## Disabling shortcuts
A shortcut can be disabled, also known as _unassigned_, by assigning the
shortcut to an empty array `[]`. For example, to introduce a new shortcut that
is disabled by default, a command can be defined like this:
```javascript
export const MAKE_MOCHA = 'foodAndBeverage.makeMocha';
{
description: s__('KeyboardShortcuts|Make a mocha'),
command: MAKE_MOCHA,
defaultKeys: [],
customKeys: customizations[MAKE_MOCHA],
}
```
## Make cross-platform shortcuts
It's difficult to make shortcuts that work well in all platforms and browsers.
This is one of the reasons that being able to customize and disable shortcuts is
so important.
One important way to make keyboard shortcuts more portable is to use the `mod`
shortcut string, which resolves to `command` on Mac and `ctrl` otherwise.
See [Mousetrap's documentation](https://craig.is/killing/mice#api.bind.combo)
for more information.
......@@ -14768,6 +14768,12 @@ msgstr ""
msgid "KeyboardKey|Ctrl+"
msgstr ""
msgid "KeyboardShortcuts|Global Shortcuts"
msgstr ""
msgid "KeyboardShortcuts|Toggle the Performance Bar"
msgstr ""
msgid "Keys"
msgstr ""
......
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
describe('~/behaviors/shortcuts/keybindings.js', () => {
let keysFor;
let TOGGLE_PERFORMANCE_BAR;
let LOCAL_STORAGE_KEY;
beforeAll(() => {
useLocalStorageSpy();
});
const setupCustomizations = async customizationsAsString => {
localStorage.clear();
if (customizationsAsString) {
localStorage.setItem(LOCAL_STORAGE_KEY, customizationsAsString);
}
jest.resetModules();
({ keysFor, TOGGLE_PERFORMANCE_BAR, LOCAL_STORAGE_KEY } = await import(
'~/behaviors/shortcuts/keybindings'
));
};
describe('when a command has not been customized', () => {
beforeEach(async () => {
await setupCustomizations('{}');
});
it('returns the default keybinding for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']);
});
});
describe('when a command has been customized', () => {
const customization = ['p b a r'];
beforeEach(async () => {
await setupCustomizations(JSON.stringify({ [TOGGLE_PERFORMANCE_BAR]: customization }));
});
it('returns the default keybinding for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(customization);
});
});
describe("when the localStorage entry isn't valid JSON", () => {
beforeEach(async () => {
await setupCustomizations('{');
});
it('returns the default keybinding for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']);
});
});
describe(`when localStorage doesn't contain the ${LOCAL_STORAGE_KEY} key`, () => {
beforeEach(async () => {
await setupCustomizations();
});
it('returns the default keybinding for the command', () => {
expect(keysFor(TOGGLE_PERFORMANCE_BAR)).toEqual(['p b']);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment