greena13/react-hotkeys

View on GitHub
src/lib/strategies/AbstractKeyEventStrategy.js

Summary

Maintainability
A
0 mins
Test Coverage
import KeyEventType from '../../const/KeyEventType';

import Configuration from '../config/Configuration';
import KeyHistory from '../listening/KeyHistory';
import KeyCombination from '../listening/KeyCombination';
import ComponentTree from '../definitions/ComponentTree';
import ComponentOptionsList from '../definitions/ComponentOptionsList';
import ActionResolver from '../matching/ActionResolver';

import printComponent from '../../helpers/logging/printComponent';
import stateFromEvent from '../../helpers/parsing-key-maps/stateFromEvent';
import KeyCombinationDecorator from '../listening/KeyCombinationDecorator';
import lazyLoadAttribute from '../../utils/object/lazyLoadAttribute';

/**
 * Defines common behaviour for key event strategies
 * @abstract
 * @class
 */
class AbstractKeyEventStrategy {
  /********************************************************************************
   * Init & Reset
   ********************************************************************************/

  /**
   * Creates a new instance of an event strategy (this class is an abstract one and
   * not intended to be instantiated directly).
   * @param {Object} options Options for how event strategy should behave
   * @param {string} options.logLevel The level of severity to log at
   * @param {KeyEventManager} keyEventManager KeyEventManager used for passing
   *        messages between key event strategies
   */
  constructor(options = {}, keyEventManager) {
    /**
     * @typedef {number} ComponentId Unique index associated with every HotKeys component
     * as it becomes active.
     *
     * For focus-only components, this happens when the component is focused. The HotKeys
     * component closest to the DOM element in focus gets the smallest number (0) and
     * those further up the render tree get larger (incrementing) numbers. When a different
     * element is focused (triggering the creation of a new focus tree) all component indexes
     * are reset (de-allocated) and re-assigned to the new tree of HotKeys components that
     * are now in focus.
     *
     * For global components, component indexes are assigned when a HotKeys component is
     * mounted, and de-allocated when it unmounts. The component index counter is never reset
     * back to 0 and just keeps incrementing as new components are mounted.
     */

    /**
     * Should be overridden by children to set a Logger instance
     */
    this.logger = null;

    /**
     * Counter to maintain what the next component index should be
     * @type {ComponentId}
     */
    this.componentId = -1;

    /**
     * Reference to key event manager, so that information may pass between the
     * global strategy and the focus-only strategy
     * @type {KeyEventManager}
     */
    this.keyEventManager = keyEventManager;

    this.componentTree = new ComponentTree();

    /**
     * Expected to be overridden by child class
     * @type {AbstractKeyEventSimulator}
     * @abstract
     */
    this._simulator = null;

    this._reset();

    this.resetKeyHistory();
  }

  /**
   * Resets all strategy state to the values it had when it was first created
   * @protected
   */
  _reset() {
    this.componentList = new ComponentOptionsList();

    this._actionResolver = null;
  }

  _recalculate() {
    this._actionResolver = null;

    this.keyHistory.maxLength = this.componentList.longestSequence;
  }

  get keyHistory() {
    return lazyLoadAttribute(this, '_keyHistory', () => this._newKeyHistory());
  }

  get actionResolver() {
    return lazyLoadAttribute(this, '_actionResolver', () => new ActionResolver(this.componentList, this, this.logger));
  }

  /**
   * Reset the state values that record the current and recent state of key events
   * @param {Object} options An options hash
   * @param {boolean} options.force Whether to force a hard reset of the key
   *        combination history.
   */
  resetKeyHistory(options = {}) {
    if (this._simulator) {
      this._simulator.clear();
    }

    if (this.keyHistory.any() && !options.force) {
      this._keyHistory = new KeyHistory(
        { maxLength: this.componentList.longestSequence },
        new KeyCombination(this)
      );
    } else {
      this._keyHistory = this._newKeyHistory();
    }
  }

  _newKeyHistory() {
    return new KeyHistory({
      maxLength: this.componentList.longestSequence
    });
  }

  /********************************************************************************
   * Registering key maps
   ********************************************************************************/

  /**
   * Registers a new mounted component's key map so that it can be included in the
   * application's key map
   * @param {KeyMap} keyMap - Map of actions to key expressions
   * @returns {ComponentId} Unique component ID to assign to the focused HotKeys
   *          component and passed back when handling a key event
   */
  registerKeyMap(keyMap) {
    this.componentId += 1;

    this.componentTree.add(this.componentId, keyMap);

    this.logger.verbose(
      this.logger.nonKeyEventPrefix(this.componentId, { focusTreeId: false }),
      'Registered component in application key map:\n',
      `${printComponent(this.componentTree.get(this.componentId))}`
    );

    return this.componentId;
  }

  /**
   * Re-registers (updates) a mounted component's key map
   * @param {ComponentId} componentId - Id of the component that the keyMap belongs to
   * @param {KeyMap} keyMap - Map of actions to key expressions
   */
  reregisterKeyMap(componentId, keyMap) {
    this.componentTree.update(componentId, keyMap);
  }

  /**
   * Registers that a component has now mounted, and declares its parent hot keys
   * component id so that actions may be properly resolved
   * @param {ComponentId} componentId - Id of the component that has mounted
   * @param {ComponentId} parentId - Id of the parent hot keys component
   */
  registerComponentMount(componentId, parentId) {
    this.componentTree.setParent(componentId, parentId);

    this.logger.verbose(
      this.logger.nonKeyEventPrefix(componentId),
      'Registered component mount:\n',
      `${printComponent(this.componentTree.get(componentId))}`
    );
  }

  /**
   * De-registers (removes) a mounted component's key map from the registry
   * @param {ComponentId} componentId - Id of the component that the keyMap
   *        belongs to
   */
  deregisterKeyMap(componentId) {
    this.componentTree.remove(componentId);

    this.logger.verbose(
      this.logger.nonKeyEventPrefix(componentId),
      'De-registered component. Remaining component Registry:\n',
      `${printComponent(this.componentTree.toJSON())}`
    );

    if (this.componentTree.isRootId(componentId)) {
      this.componentTree.clearRootId();
    }
  }

  /**
   * Registers the hotkeys defined by a HotKeys component
   * @param {ComponentId} componentId - Index of the component
   * @param {KeyMap} actionNameToKeyMap - Definition of actions and key maps defined
   *        in the HotKeys component
   * @param {HandlersMap} actionNameToHandlersMap - Map of ActionNames to handlers
   *        defined in the HotKeys component
   * @param {string} action - Description of the action that triggers the new component
   *        registering a new key map.
   * @param {Object} options - Hash of options that configure how the key map is built.
   * @protected
   */
  _addComponent(componentId, actionNameToKeyMap = {}, actionNameToHandlersMap = {}, action, options) {
    this.componentList.add(componentId,
      actionNameToKeyMap, actionNameToHandlersMap, options
    );

    this._recalculate();

    this.logger.debug(this.logger.nonKeyEventPrefix(componentId), action);
    this.logger.logComponentOptions(componentId, this.componentList.getById(componentId));
  }

  _updateComponent(componentId, actionNameToKeyMap, actionNameToHandlersMap, options) {
    this.componentList.update(
      componentId, actionNameToKeyMap, actionNameToHandlersMap, options
    );

    this._recalculate();

    this.logger.logComponentOptions(componentId, this.componentList.getById(componentId));
  }

  /********************************************************************************
   * Recording key events
   ********************************************************************************/

  get currentCombination() {
    return this.keyHistory.currentCombination;
  }

  _describeCurrentCombination() {
    const keyCombinationDecorator = new KeyCombinationDecorator(this.currentCombination);
    return keyCombinationDecorator.describe();
  }

  _recordKeyDown(event, key, componentId) {
    const keyEventState = stateFromEvent(event);

    const currentCombination = this.currentCombination;

    if (currentCombination.isKeyIncluded(key) || currentCombination.isEnding) {
      this._startAndLogNewKeyCombination(componentId, key, keyEventState);
    } else {
      this._recordNewKeyInCombination(key, KeyEventType.keydown, keyEventState, componentId);
    }
  }

  _startAndLogNewKeyCombination(componentId, keyName, keyEventState) {
    this.keyHistory.startNewKeyCombination(keyName, keyEventState);

    this.logger.verbose(
      this.logger.keyEventPrefix(componentId),
      `Started a new combination with '${keyName}'.`
    );

    this.logger.logKeyHistory(this.keyHistory, componentId);
  }

  _recordNewKeyInCombination(keyName, keyEventType, keyEventState, componentId) {
    this.keyHistory.addKeyToCurrentCombination(keyName, keyEventType, keyEventState);

    if (keyEventType === KeyEventType.keydown) {
      this.logger.verbose(
        this.logger.keyEventPrefix(componentId),
        `Added '${keyName}' to current combination: '${this._describeCurrentCombination()}'.`
      );
    }

    this.logger.logKeyHistory(this.keyHistory, componentId);
  }

  /********************************************************************************
   * Matching and calling handlers
   ********************************************************************************/

  _isIgnoringRepeatedEvent(event, key, eventType, componentId) {
    if (event.repeat && Configuration.option('ignoreRepeatedEventsWhenKeyHeldDown')) {
      this.logger.logIgnoredKeyEvent(event, key, eventType, 'it was a repeated event', componentId);

      return true;
    }

    return false;
  }
}

export default AbstractKeyEventStrategy;