Skip to content

Commit

Permalink
🌟 Fix #158: Add a recordKeyCombination function to set dynamic keyboa…
Browse files Browse the repository at this point in the history
…rd shortcuts
  • Loading branch information
greena13 committed Jun 16, 2019
1 parent 275ff09 commit f4cdbd8
Show file tree
Hide file tree
Showing 9 changed files with 531 additions and 35 deletions.
181 changes: 148 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ See the [upgrade notes](https://github.com/greena13/react-hotkeys/releases/tag/v
- [Browser key names](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) and [Mousetrap syntax](https://github.com/ccampbell/mousetrap)
- Define [global](#GlobalHotKeys-component) and [in-focus](#HotKeys-component) hot keys
- [Display a list of available hot keys to the user](#Displaying-a-list-of-available-hot-keys)
- [Defining custom key codes](#defining-custom-key-codes) for WebOS and other environments
- [Define custom key codes](#defining-custom-key-codes) for WebOS and other environments
- Allow users to [set their own keyboard shortcuts](#setting-dynamic-hotkeys)
- Works with React's Synthetic KeyboardEvents and event delegation and provides [predictable and expected behaviour](#Interaction-with-React) to anyone familiar with React
- Optimized by default, but allows you to turn off different optimisation measures in a granular fashion
- Customizable through a simple [configuration API](#Configuration)
Expand Down Expand Up @@ -93,6 +94,7 @@ export default MyNode;
- [Specifying key map display data](#specifying-key-map-display-data)
- [Deciding which key map syntax to use](#deciding-which-key-map-syntax-to-use)
- [Defining custom key codes](#defining-custom-key-codes)
- [Setting dynamic hotkeys](#setting-dynamic-hotkeys)
- [Defining Handlers](#defining-handlers)
- [DEPRECATED: Hard Sequence Handlers](#deprecated-hard-sequence-handlers)
- [Interaction with React](#interaction-with-react)
Expand Down Expand Up @@ -372,6 +374,119 @@ const keyMap = {
};
```

#### Setting dynamic hotkeys

`react-hotkeys` has basic support for setting dynamic hotkeys - i.e. letting the user set their own keyboard shortcuts. In your app, you can set up the necessary UI for [viewing the current keyboard shortcuts](#displaying-a-list-of-available-hot-keys), and opting to change them. You can then use the `recordKeyCombination` function to capture the keys the user wishes to use.

`recordKeyCombination` accepts a callback function that will be called on the last `keyup` of the next key combination - immediately after the user has pressed the key combination they wish to assign. The callback then unbinds itself, so you do not have to worry about tidying up after it.

`recordKeyCombination` returns a function you can call at any time after binding the listener, to cancel listening without waiting for the key combination to complete.

The callback function receives a single argument with the following schema:

```javascript
{
/**
* Id of combination that could be used to define a keymap
*/
id: '',
/**
* Dictionary of keys involved in the combination
*/
keys: { keyName: true }
}
```

A basic example is:

```javascript
import { recordKeyCombination } from 'react-hotkeys';

renderDialog(){
if (this.state.showShortcutsDialog) {
const keyMap = getApplicationKeyMap();

return (
<div style={styles.DIALOG}>
<h2>
Keyboard shortcuts
</h2>

<table>
<tbody>
{
Object.keys(keyMap).reduce((memo, actionName) => {
const { sequences, name } = keyMap[actionName];

memo.push(
<tr key={name || actionName}>
<td style={styles.KEYMAP_TABLE_CELL}>
{ name }
</td>
<td style={styles.KEYMAP_TABLE_CELL}>
{ sequences.map(({sequence}) => <span key={sequence}>{sequence}</span>) }
</td>
<td style={styles.KEYMAP_TABLE_CELL}>
<button onClick={ () => this.showChangeShortcutDialog(actionName) }>
Change
</button>
</td>
</tr>
)
}
}
</tbody>
</table>
</div>
);
} else if (this.state.changingActionShortcut) {
const { cancel } = this.state.changingActionShortcut;

const keyMap = getApplicationKeyMap();
const { name } = keyMap[this.state.changingActionShortcut];

return (
<div style={styles.DIALOG}>
Press the keys you would like to bind to #{name}.

<button onClick={cancel}>
Cancel
</button>
</div>
);
}
}

showChangeShortcutDialog(actionName) {
const cancelListening = recordKeyCombination(({id}) => {
this.setState({
showShortcutsDialog: true,
changingActionShortcut: null,
keyMap: {
...this.state.keyMap,
[actionName]: id
}
});
});

this.setState({
showShortcutsDialog: false,
changingActionShortcut: {
cancel: () => {
cancelListening();

this.setState({
showShortcutsDialog: true,
changingActionShortcut: null
});
}
}
});
}
```
If you are updating hotkeys without changing focus or remounting the component that defines them, you will need to make sure you use the [`allowChanges` prop](#hotkeys-component-api) to ensure the new keymaps are honoured immediately.
## Defining Handlers
Key maps trigger actions when they match a key sequence. Handlers are the functions that `react-hotkeys` calls to handle those actions.
Expand Down Expand Up @@ -742,39 +857,39 @@ import { getApplicationKeyMap } from 'react-hotkeys';
// ...
renderDialog() {
if (this.state.showDialog) {
const keyMap = getApplicationKeyMap();

return (
<div style={styles.DIALOG}>
<h2>
Keyboard shortcuts
</h2>

<table>
<tbody>
{ Object.keys(keyMap).reduce((memo, actionName) => {
const { sequences, name } = keyMap[actionName];

memo.push(
<tr key={name || actionName}>
<td style={styles.KEYMAP_TABLE_CELL}>
{ name }
</td>
<td style={styles.KEYMAP_TABLE_CELL}>
{ sequences.map(({sequence}) => <span key={sequence}>{sequence}</span>) }
</td>
</tr>
)
}
}
</tbody>
</table>
</div>
);
}
renderDialog(){
if (this.state.showDialog) {
const keyMap = getApplicationKeyMap();
return (
<div style={styles.DIALOG}>
<h2>
Keyboard shortcuts
</h2>
<table>
<tbody>
{ Object.keys(keyMap).reduce((memo, actionName) => {
const { sequences, name } = keyMap[actionName];
memo.push(
<tr key={name || actionName}>
<td style={styles.KEYMAP_TABLE_CELL}>
{ name }
</td>
<td style={styles.KEYMAP_TABLE_CELL}>
{ sequences.map(({sequence}) => <span key={sequence}>{sequence}</span>) }
</td>
</tr>
)
}
}
</tbody>
</table>
</div>
);
}
}
```
## Allowing hotkeys and handlers props to change
Expand Down
28 changes: 28 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as React from 'react';
export type MouseTrapKeySequence = string | Array<string>;

export type ActionName = string;
export type KeyName = string;

export type KeyEventName = 'keyup' | 'keydown' | 'keypress';

Expand Down Expand Up @@ -203,6 +204,33 @@ export type ApplicationKeyMap = { [key in ActionName]: KeyMapDisplayOptions };
*/
export declare function getApplicationKeyMap(): ApplicationKeyMap;

/**
* Description of key combination passed to the callback registered with
* the recordKeyCombination function
*/
export interface KeyCombination {
/**
* Id of combination that could be used to define a keymap
*/
id: MouseTrapKeySequence;
/**
* Dictionary of keys involved in the combination
*/
keys: { [key in KeyName]: true };
}

/**
* Function to call to cancel listening to the next key combination
*/
declare type cancelKeyCombinationListener = () => void;

/**
* Adds a listener function that will be called the next time a key combination completes
* Returns a function to cancel listening.
*/
export declare function recordKeyCombination(callbackFunction: (keyCombination: KeyCombination) => void): cancelKeyCombinationListener;


export interface ConfigurationOptions {
/**
* The level of logging of its own behaviour React HotKeys should perform. Default
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export {default as withObserveKeys} from './withObserveKeys';
export {default as configure} from './configure';

export {default as getApplicationKeyMap} from './getApplicationKeyMap';
export {default as recordKeyCombination} from './recordKeyCombination';

7 changes: 7 additions & 0 deletions src/lib/KeyEventManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ class KeyEventManager {
this._globalEventStrategy.deregisterKeyMap(componentId);
}

/********************************************************************************
* Recording key combination
********************************************************************************/
addKeyCombinationListener(callbackFunction) {
return this._globalEventStrategy.addKeyCombinationListener(callbackFunction);
}

/********************************************************************************
* Focus key events
********************************************************************************/
Expand Down
13 changes: 13 additions & 0 deletions src/lib/strategies/AbstractKeyEventStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,19 @@ class AbstractKeyEventStrategy {
this.keyCombinationIncludesKeyUp = false;
}

/**
* Whether there are any keys in the current combination still being pressed
* @protected
* @return {Boolean} True if all keys in the current combination are released
*/
_allKeysAreReleased() {
const currentCombination = this._getCurrentKeyCombination();

return Object.keys(currentCombination.keys).every((keyName) => {
return !this._keyIsCurrentlyDown(keyName);
});
}

/**
* Returns a new KeyCombinationRecord without the keys that have been
* released (had the keyup event recorded). Essentially, the keys that are
Expand Down
49 changes: 48 additions & 1 deletion src/lib/strategies/GlobalKeyEventStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import describeKeyEvent from '../../helpers/logging/describeKeyEvent';
import isCmdKey from '../../helpers/parsing-key-maps/isCmdKey';
import EventResponse from '../../const/EventResponse';
import contains from '../../utils/collection/contains';
import dictionaryFrom from '../../utils/object/dictionaryFrom';

/**
* Defines behaviour for dealing with key maps defined in global HotKey components
Expand Down Expand Up @@ -45,6 +46,12 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
this.eventOptions = {
ignoreEventsCondition: Configuration.option('ignoreEventsCondition')
};

/**
* Dictionary of listener functions - currently only intended to house
* keyCombinationListener
*/
this.listeners = {};
}

/********************************************************************************
Expand Down Expand Up @@ -201,7 +208,7 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
}

_updateDocumentHandlers(){
const listenersShouldBeBound = this.componentList.length > 0;
const listenersShouldBeBound = this._listenersShouldBeBound();

if (!this.listenersBound && listenersShouldBeBound) {
for(let recordIndex = 0; recordIndex < this.keyMapEventRecord.length; recordIndex++) {
Expand Down Expand Up @@ -236,6 +243,10 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
}
}

_listenersShouldBeBound() {
return this.componentList.length > 0 || this.listeners.keyCombination;
}

/********************************************************************************
* Recording key events
********************************************************************************/
Expand Down Expand Up @@ -505,6 +516,15 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
* of whether the event should be ignored or not
*/
this._simulateKeyUpEventsHiddenByCmd(event, key);

if (this.listeners.keyCombination && this._allKeysAreReleased()) {
const {keys,ids} = this._getCurrentKeyCombination();

this.listeners.keyCombination({
keys: dictionaryFrom(Object.keys(keys), true),
id: ids[0]
});
}
}

_simulateKeyPressesMissingFromBrowser(event, key) {
Expand Down Expand Up @@ -653,6 +673,33 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
}
}

/********************************************************************************
* Recording key combination
********************************************************************************/

/**
* Add a new key combination listener function to be called the next time a key
* combination completes (assuming the cancel function is not called).
* @param {keyCombinationListener} callbackFunction Function to call with the next
* completed key combination
* @returns {function} Function to call to cancel listening for the next key
* combination
*/
addKeyCombinationListener(callbackFunction) {
const cancel = () => {
delete this.listeners.keyCombination;
};

this.listeners.keyCombination = (keyCombination) => {
callbackFunction(keyCombination);
cancel();
};

this._updateDocumentHandlers();

return cancel;
}

/********************************************************************************
* Logging
********************************************************************************/
Expand Down
Loading

0 comments on commit f4cdbd8

Please sign in to comment.