src/lib/strategies/GlobalKeyEventStrategy.js
import KeyEventType from '../../const/KeyEventType';
import AbstractKeyEventStrategy from './AbstractKeyEventStrategy';
import describeKeyEventType from '../../helpers/logging/describeKeyEventType';
import KeyEventCounter from '../listening/KeyEventCounter';
import getKeyName from '../../helpers/resolving-handlers/getKeyName';
import Configuration from '../config/Configuration';
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 stateFromEvent from '../../helpers/parsing-key-maps/stateFromEvent';
import GlobalKeyEventSimulator from '../simulation/GlobalKeyEventSimulator';
import GlobalEventListenerAdaptor from '../listening/GlobalEventListenerAdaptor';
import Registry from '../shared/Registry';
import GlobalLogger from '../logging/GlobalLogger';
import KeyCombinationDecorator from '../listening/KeyCombinationDecorator';
import KeyCombinationIterator from '../listening/KeyCombinationIterator';
/**
* Defines behaviour for dealing with key maps defined in global HotKey components
* @class
*/
class GlobalKeyEventStrategy extends AbstractKeyEventStrategy {
/********************************************************************************
* Init & Reset
********************************************************************************/
constructor(options = {}, keyEventManager) {
/**
* Set state that gets cleared every time a component gets mounted or unmounted
*/
super(options, keyEventManager);
this.logger = new GlobalLogger(options.logLevel || 'warn', this);
/**
* Set state that doesn't get cleared each time a new new component is mounted
* or unmounted
* @type {number}
*/
this.eventOptions = {
ignoreEventsCondition: Configuration.option('ignoreEventsCondition')
};
/**
* Dictionary of listener functions - currently only intended to house
* keyCombinationListener
*/
this.listeners = new Registry();
this._simulator = new GlobalKeyEventSimulator(this);
this._listenerAdaptor = new GlobalEventListenerAdaptor(this, { logger: this.logger });
}
/********************************************************************************
* Enabling key maps and handlers
********************************************************************************/
/**
* Registers the actions and handlers of a HotKeys component that has mounted
* @param {ComponentId} componentId - Id of the component that the keyMap belongs to
* @param {KeyMap} actionNameToKeyMap - Map of actions to key expressions
* @param {HandlersMap} actionNameToHandlersMap - Map of actions to handler functions
* @param {Object} options Hash of options that configure how the actions
* and handlers are associated and called.
* @param {Object} eventOptions - Options for how the event should be handled
*/
enableHotKeys(componentId, actionNameToKeyMap = {}, actionNameToHandlersMap = {}, options, eventOptions) {
this.eventOptions = eventOptions;
this._addComponent(
componentId, actionNameToKeyMap, actionNameToHandlersMap, 'Mounted', options
);
}
/**
* Handles when a mounted global HotKeys component updates its props and changes
* either the keyMap or handlers prop value
* @param {ComponentId} componentId - The component index of the component to
* update
* @param {KeyMap} keyMap - Map of actions to key expressions
* @param {HandlersMap} handlersMap - Map of actions to handler functions
* @param {Object} options Hash of options that configure how the actions
* and handlers are associated and called.
* @param {Object} eventOptions - Options for how the event should be handled
*/
updateEnabledHotKeys(componentId, keyMap = {}, handlersMap = {}, options, eventOptions) {
this.eventOptions = eventOptions;
this._updateComponent(componentId, keyMap, handlersMap, options);
}
/**
* Handles when a component is unmounted
* @param {ComponentId} componentId - Index of component that is being unmounted
*/
disableHotKeys(componentId) {
/**
* Manually update the registered key map state, usually reset using
* _resetRegisteredKeyMapsState() method
*/
this.componentList.remove(componentId);
this.logger.debug(
this.logger.nonKeyEventPrefix(componentId),
`Unmounted global component ${componentId}`
);
this._recalculate();
}
_recalculate() {
super._recalculate();
this._updateDocumentHandlers();
}
_updateDocumentHandlers(){
const listenersShouldBeBound = this._shouldListenersBeBound();
const listenersAreBound = this.isListenersBound();
if (!listenersAreBound && listenersShouldBeBound) {
this._listenerAdaptor.bindListeners();
} else if (listenersAreBound && !listenersShouldBeBound) {
this._listenerAdaptor.unbindListeners();
}
}
isListenersBound() {
return this._listenerAdaptor.isListenersBound();
}
/**
* Whether the document listeners should be bound, to record key events. Basically a check
* to see if there are any global key maps, or whether the user is currently rebinding to
* a new key combination.
* @returns {boolean} True if the document listeners should be bound
* @private
*/
_shouldListenersBeBound() {
return this.componentList.length !== 0 || this.listeners.get('keyCombination');
}
/********************************************************************************
* Recording key events
********************************************************************************/
/**
* Records a keydown keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called once when a keyboard event bubbles up to document, and checks
* the keymaps for all of the mounted global HotKey components.
* @param {SyntheticKeyboardEvent} event - Event containing the key name and state
*/
handleKeyDown(event) {
const key = getKeyName(event);
if (this._isIgnoringRepeatedEvent(event, key, KeyEventType.keydown)) {
return;
}
this.currentCombination.resolveModifierFlagDiscrepancies(event, key, KeyEventType.keydown);
const reactAppResponse =
this._howReactAppRespondedTo(event, key, KeyEventType.keydown);
if (reactAppResponse === EventResponse.unseen && this.eventOptions.ignoreEventsCondition(event)) {
this.logger.logEventRejectedByFilter(event, key, KeyEventType.keydown);
return;
}
if (reactAppResponse !== EventResponse.ignored) {
this._recordKeyDown(event, key);
}
this._callHandlerIfNeeded(reactAppResponse, event, key, KeyEventType.keydown);
this.keyEventManager.simulatePendingKeyPressEvents();
this._simulator.handleKeyPressSimulation({event, key});
}
_howReactAppRespondedTo(event, key, keyEventType) {
const reactAppHistoryWithEvent =
this.keyEventManager.reactAppHistoryWithEvent(key, keyEventType);
switch(reactAppHistoryWithEvent) {
case EventResponse.handled:
this.logger.logIgnoredKeyEvent(event, key, keyEventType, 'React app has already handled it');
break;
case EventResponse.ignored:
this.logger.logIgnoredKeyEvent(event, key, keyEventType, 'React app has declared it should be ignored');
break;
case EventResponse.seen:
this.logger.debug(
this.logger.keyEventPrefix(),
`Received ${describeKeyEvent(event, key, keyEventType)} event (that has already passed through React app).`
);
break;
default:
KeyEventCounter.incrementId();
this.logger.debug(
this.logger.keyEventPrefix(),
`New ${describeKeyEvent(event, key, keyEventType)} event (that has NOT passed through React app).`
);
}
return reactAppHistoryWithEvent;
}
/**
* Records a keypress keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called once when a keyboard event bubbles up to document, and checks
* the keymaps for all of the mounted global HotKey components.
* @param {SyntheticKeyboardEvent} event - Event containing the key name and state
*/
handleKeyPress(event) {
const key = getKeyName(event);
if (this._isIgnoringRepeatedEvent(event, key, KeyEventType.keypress)) {
return;
} else if (this.currentCombination.isKeyPressSimulated(key)){
this.logger.logEventAlreadySimulated(event, key, KeyEventType.keypress);
return;
}
/**
* We first decide if the keypress event should be handled (to ensure the correct
* order of logging statements)
*/
const reactAppResponse = this._howReactAppRespondedTo(
event, key, KeyEventType.keypress
);
/**
* Add new key event to key combination history
*/
if (this.currentCombination.isKeyIncluded(key)) {
this._recordNewKeyInCombination(key, KeyEventType.keypress, stateFromEvent(event));
}
if (reactAppResponse === EventResponse.unseen) {
/**
* If the key event has not been seen by the React application, we ensure that
* it's not still waiting for it. This occurs when action handlers bound to keydown
* move the focus outside of the react app before it can record the keypress or
* keyup
*/
this.keyEventManager.closeHangingKeyCombination(key, KeyEventType.keypress);
if (this.eventOptions.ignoreEventsCondition(event)) {
this.logger.logEventRejectedByFilter(event, key, KeyEventType.keypress);
return;
}
}
this._callHandlerIfNeeded(reactAppResponse, event, key, KeyEventType.keypress);
}
_callHandlerIfNeeded(reactAppResponse, event, key, eventType) {
if (!contains([EventResponse.ignored, EventResponse.handled], reactAppResponse)) {
this._callHandlerIfExists(event, key, eventType);
}
}
/**
* Records a keyup keyboard event and matches it against the list of pre-registered
* event handlers, calling the first matching handler with the highest priority if
* one exists.
*
* This method is called once when a keyboard event bubbles up to document, and checks
* the keymaps for all of the mounted global HotKey components.
* @param {SyntheticKeyboardEvent} event - Event containing the key name and state
*/
handleKeyUp(event) {
const key = getKeyName(event);
if (this.currentCombination.isKeyUpSimulated(key)){
this.logger.logEventAlreadySimulated(event, key, KeyEventType.keyup);
return;
}
/**
* We first decide if the keyup event should be handled (to ensure the correct
* order of logging statements)
*/
const reactAppResponse = this._howReactAppRespondedTo(event, key, KeyEventType.keyup);
/**
* We then add the keyup to our current combination - regardless of whether
* it's to be handled or not. We need to do this to ensure that if a handler
* function changes focus to a context that ignored events, the keyup event
* is not lost (leaving react hotkeys thinking the key is still pressed).
*/
if (this.currentCombination.isKeyIncluded(key)) {
this._recordNewKeyInCombination(
key, KeyEventType.keyup, stateFromEvent(event)
);
}
if (reactAppResponse === EventResponse.unseen){
/**
* If the key event has not been seen by the React application, we ensure that
* it's not still waiting for it. This occurs when action handlers bound to keydown
* or keypress move the focus outside of the react app before it can record the keyup
*/
this.keyEventManager.closeHangingKeyCombination(key, KeyEventType.keyup);
if(this.eventOptions.ignoreEventsCondition(event)) {
this.logger.logIgnoredKeyEvent(
event, key, KeyEventType.keyup, 'ignoreEventsFilter rejected it'
);
} else {
this._callHandlerIfNeeded(reactAppResponse, event, key, KeyEventType.keyup);
}
} else {
this._callHandlerIfNeeded(reactAppResponse, event, key, KeyEventType.keyup);
}
/**
* We simulate any hidden keyup events hidden by the command key, regardless
* of whether the event should be ignored or not
*/
this._simulateKeyUpEventsHiddenByCmd(event, key);
this._callKeyCombinationCallbackIfDefined();
}
_simulateKeyUpEventsHiddenByCmd(event, key) {
if (isCmdKey(key)) {
/**
* We simulate pending key events in the React app before we do it globally
*/
this.keyEventManager.simulatePendingKeyUpEvents();
const iterator = new KeyCombinationIterator(this.currentCombination);
iterator.forEachKey((keyName) => {
if (isCmdKey(keyName)) {
return;
}
this._simulator.handleKeyUpSimulation({event, key: keyName});
});
}
}
_callKeyCombinationCallbackIfDefined() {
if (this.listeners.get('keyCombination') && this.currentCombination.hasEnded()) {
const keyCombinationDecorator = new KeyCombinationDecorator(this.currentCombination);
this.listeners.get('keyCombination')({
keys: keyCombinationDecorator.asKeyDictionary(),
id: keyCombinationDecorator.describe()
});
}
}
/********************************************************************************
* Matching and calling handlers
********************************************************************************/
_callHandlerIfExists(event, keyName, keyEventType) {
const eventName = describeKeyEventType(keyEventType);
const combinationName = this._describeCurrentCombination();
if (!this.componentList.anyActionsForEventType(keyEventType)) {
/**
* If there are no handlers registered for the particular key event type
* (keydown, keypress, keyup) then skip trying to find a matching handler
* for the current key combination
*/
this.logger.logIgnoredEvent(`'${combinationName}' ${eventName}`, `it doesn't have any ${eventName} handlers`);
return;
}
/**
* If there is at least one handler for the specified key event type (keydown,
* keypress, keyup), then attempt to find a handler that matches the current
* key combination
*/
this.logger.verbose(
this.logger.keyEventPrefix(),
`Attempting to find action matching '${combinationName}' ${eventName} . . .`
);
this._callClosestMatchingHandler(event, keyName, keyEventType);
}
_callClosestMatchingHandler(event, keyName, keyEventType) {
const componentListIterator = this.componentList.iterator;
while (componentListIterator.next()) {
const matchFound = this.actionResolver.callClosestMatchingHandler(
event, keyName, keyEventType,
componentListIterator.position,
0
);
if (matchFound) {
this.logger.debug(this.logger.keyEventPrefix(), `Searching no further, as handler has been found (and called).`);
return;
}
}
}
stopEventPropagation(event, componentId) {
this.logger.debug(
this.logger.keyEventPrefix(componentId),
'Stopping further event propagation.'
);
if (!event.simulated) {
event.stopPropagation();
}
}
/********************************************************************************
* 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 = () => {
this.listeners.remove('keyCombination');
};
this.listeners.set('keyCombination', (keyCombination) => {
callbackFunction(keyCombination);
cancel();
});
this._updateDocumentHandlers();
return cancel;
}
}
export default GlobalKeyEventStrategy;