greena13/react-hotkeys

View on GitHub
src/lib/matching/ActionResolver.js

Summary

Maintainability
A
0 mins
Test Coverage
import KeyHistoryMatcher from './KeyHistoryMatcher';
import lazyLoadAttribute from '../../utils/object/lazyLoadAttribute';
import printComponent from '../../helpers/logging/printComponent';
import Configuration from '../config/Configuration';
import KeyCombinationSerializer from '../shared/KeyCombinationSerializer';
import describeKeyEventType from '../../helpers/logging/describeKeyEventType';
import KeyCombinationDecorator from '../listening/KeyCombinationDecorator';

/**
 * Resolves the correct actions to trigger for a list of hotkeys components and a
 * history of key events
 * @class
 */
class ActionResolver {
  /**
   * Creates a new instance of ActionResolver
   * @param {ComponentOptionsList} componentList List of components
   * @param {AbstractKeyEventStrategy} eventStrategy
   * @param {Logger} logger
   * @returns {ActionResolver}
   */
  constructor(componentList, eventStrategy, logger) {
    this.logger = logger;

    this._eventStrategy = eventStrategy;

    /**
     * List of mappings from key sequences to handlers that is constructed on-the-fly
     * as key events propagate up the render tree
     * @type {KeyHistoryMatcher[]}
     */
    this._keyMapMatchers = [];

    /**
     * Array of counters - one for each component - to keep track of how many handlers
     * for that component still need actions assigned to them
     * @type {Array.<Number,Object>}
     */
    this._unmatchedHandlerStatus = [];

    /**
     * A dictionary mapping action names to the position in the list of the components
     * that define handlers for them
     * @type {Object.<ActionName, Number[]>}
     */
    this._handlersDictionary = {};

    /**
     * A dictionary of sequences already encountered in the process of building the
     * list of keyMaps on the fly, as key events propagate up the component tree
     * @type {Object.<MouseTrapKeySequence, Number[]>}
     */
    this._keySequencesDictionary = {};

    const iterator = componentList.iterator;

    while(iterator.next()) {
      const { handlers } = iterator.getComponent();
      this._unmatchedHandlerStatus.push( [ Object.keys(handlers).length, {} ]);
      this._keyMapMatchers.push(new KeyHistoryMatcher());
    }

    this._componentList = componentList;
    this._componentListIterator = componentList.iterator;
  }

  /**
   * The KeyHistoryMatcher for the component in a particular position
   * @param {number} componentPosition Position of component to find the
   *        KeyHistoryMatcher for
   * @returns {KeyHistoryMatcher} Key combination matcher that corresponds
   *        to the component
   */
  getKeyHistoryMatcher(componentPosition) {
    if (this._componentHasUnmatchedHandlers(componentPosition)) {
      /**
       * We build the mapping between actions and their closest handlers the
       * first time the key map for the component at <tt>position</tt> is accessed.
       *
       * We must search higher than the current component for actions, as they are
       * often defined in parent components of those that ultimately define their
       * handlers.
       */
      while (this._componentListIterator.next()) {
        this._addHandlersFromComponent();
        this._addActionsFromComponent();
      }
    }

    return this._getKeyHistoryMatcher(componentPosition);
  }

  /**
   * Whether a component has one or more actions bound to an event type
   * @param {number} componentPosition Position of the component
   * @param {KeyEventType} keyEventType
   * @returns {boolean} true if the component has an action bound to the event type
   */
  componentHasActionsBoundToEventType(componentPosition, keyEventType) {
    return this.getKeyHistoryMatcher(componentPosition).hasMatchesForEventType(keyEventType);
  }

  /**
   * Finds matcher for sequence and current key event for a component at a position
   * @param {number} componentPosition Position of the component
   * @param {KeyHistory} keyHistory History of key combinations to match
   *        against actions defined in component
   * @param {ReactKeyName} keyName Name of the key the current event relates to
   * @param {KeyEventType} keyEventType Type of key event
   * @returns {Object|null}
   */
  findMatchingKeySequenceInComponent(componentPosition, keyHistory, keyName, keyEventType) {
    if (!this.componentHasActionsBoundToEventType(componentPosition, keyEventType)) {
      return null;
    }

    return this.getKeyHistoryMatcher(componentPosition).findMatch(
      keyHistory,
      keyName,
      keyEventType
    )
  }

  callClosestMatchingHandler(event, keyName, keyEventType, componentPosition, componentSearchIndex) {
    while (componentSearchIndex <= componentPosition) {
      const keyHistoryMatcher =
        this.getKeyHistoryMatcher(componentSearchIndex);

      const { componentId } =
        this._eventStrategy.componentList.getAtPosition(componentSearchIndex);

      this.logger.verbose(
        this.logger.keyEventPrefix(componentId),
        'Internal key mapping:\n',
        `${printComponent(keyHistoryMatcher.toJSON())}`
      );

      const keyHistory = this._eventStrategy.keyHistory;
      const currentCombination = keyHistory.currentCombination;

      const sequenceMatch =
        this.findMatchingKeySequenceInComponent(
          componentSearchIndex, keyHistory, keyName, keyEventType
        );

      if (sequenceMatch) {
        this._handleMatchFound(currentCombination, sequenceMatch, keyEventType, componentId, event, componentSearchIndex);

        return true;
      }

      this._handleMatchNotFound(currentCombination, componentSearchIndex, keyEventType, componentId);

      componentSearchIndex++;
    }
  }

  _handleMatchNotFound(currentCombination, componentSearchIndex, keyEventType, componentId) {
    const keyCombinationDecorator = new KeyCombinationDecorator(currentCombination);

    if (this.componentHasActionsBoundToEventType(componentSearchIndex, keyEventType)) {
      const eventName = describeKeyEventType(keyEventType);

      this.logger.debug(
        this.logger.keyEventPrefix(componentId),
        `No matching actions found for '${keyCombinationDecorator.describe()}' ${eventName}.`
      );
    } else {
      this.logger.debug(
        this.logger.keyEventPrefix(componentId),
        `Doesn't define a handler for '${keyCombinationDecorator.describe()}' ${describeKeyEventType(keyEventType)}.`
      );
    }
  }

  _handleMatchFound(currentCombination, sequenceMatch, keyEventType, componentId, event, componentSearchIndex) {
    const keyCombinationDecorator = new KeyCombinationDecorator(currentCombination);
    const eventSchema = sequenceMatch.events[keyEventType];

    if (Configuration.option('allowCombinationSubmatches')) {
      const subMatchDescription = KeyCombinationSerializer.serialize(sequenceMatch.keyDictionary);

      this.logger.debug(
        this.logger.keyEventPrefix(componentId),
        `Found action that matches '${keyCombinationDecorator.describe()}' (sub-match: '${subMatchDescription}'): ${eventSchema.actionName}. Calling handler . . .`
      );
    } else {
      this.logger.debug(
        this.logger.keyEventPrefix(componentId),
        `Found action that matches '${keyCombinationDecorator.describe()}': ${eventSchema.actionName}. Calling handler . . .`
      );
    }

    eventSchema.handler(event);

    if (Configuration.option('stopEventPropagationAfterHandling')) {
      this._eventStrategy.stopEventPropagation(event, componentSearchIndex);
    }
  }

  /********************************************************************************
   * Private methods
   *********************************************************************************/

  _getKeyHistoryMatcher(index) {
    return this._keyMapMatchers[index];
  }

  _addActionsFromComponent() {
    const {actions} = this._componentListIterator.getComponent();

    /**
     * Iterate over the actions of a component (starting with the current component
     * and working through its ancestors), matching them to the current component's
     * handlers
     */
    Object.keys(actions).forEach((actionName) => {
      this._buildActionMatchers(actionName, actions[actionName]);
    });
  }

  _buildActionMatchers(actionName, listOfActionDefinitions) {
    const handlerPositions = this._positionsOfComponentsWithHandlersFor(actionName);

    if (!handlerPositions) {
      return false;
    }

    this._registerActionWithMatcher(
      handlerPositions, actionName, listOfActionDefinitions
    );

    handlerPositions.forEach((componentPosition) => {
      const handlerStatus = this._getUnmatchedHandlerStatus(componentPosition);
      const matchedActionsDictionary = handlerStatus[1];

      if (!matchedActionsDictionary[actionName]) {
        matchedActionsDictionary[actionName] = true;

        /**
         * Decrement the number of remaining unmatched handlers for the
         * component currently handling the propagating key event, so we know
         * when all handlers have been matched to sequences and we can move on
         * to matching them against the current key event
         */
        handlerStatus[0]--;
      }
    });

    return true;
  }

  _positionsOfComponentsWithHandlersFor(actionName) {
    return this._handlersDictionary[actionName];
  }

  _registerActionWithMatcher(handlerPositions, actionName, listOfActionDefinitions) {
    const positionOfClosestHandler = handlerPositions[0];

    const closestHandler =
      this._componentList.getAtPosition(positionOfClosestHandler).handlers[actionName];

    const closestKeyMatcher = this._getKeyHistoryMatcher(positionOfClosestHandler);

    listOfActionDefinitions.forEach((actionDefinition) => {
      const {prefix, id, keyEventType} = actionDefinition;

      const sequenceId = [prefix, id].join(' ');

      if (this._isClosestHandlerAlreadyFoundFor(sequenceId, keyEventType)) {
        return false;
      }

      closestKeyMatcher.addMatch(actionDefinition, closestHandler);

      this._recordHandlerFoundFor(sequenceId, [positionOfClosestHandler, keyEventType]);
    });

    return true;
  }

  _addHandlersFromComponent() {
    const { handlers } = this._componentListIterator.getComponent();

    /**
     * Add current component's handlers to the handlersDictionary so we know
     * which component has defined them
     */
    Object.keys(handlers).forEach((actionName) => {
      this._addHandler(actionName);
    });
  }

  _addHandler(actionName) {
    lazyLoadAttribute(this._handlersDictionary, actionName, []);

    this._handlersDictionary[actionName].push(this._componentListIterator.position);
  }

  _recordHandlerFoundFor(keySequence, value) {
    /**
     * Record that we have already found a handler for the current action so
     * that we do not override handlers for an action closest to the event target
     * with handlers further up the tree
     */
    lazyLoadAttribute(this._keySequencesDictionary, keySequence, []);

    this._keySequencesDictionary[keySequence].push(value);
  }

  _componentHasUnmatchedHandlers(componentIndex) {
    return this._getUnmatchedHandlerStatus(componentIndex)[0] > 0;
  }

  _getUnmatchedHandlerStatus(index) {
    return this._unmatchedHandlerStatus[index];
  }

  _isClosestHandlerAlreadyFoundFor(keySequenceId, keyEventType) {
    const keySequence = this._keySequencesDictionary[keySequenceId] || [];

    return keySequence.some((dictEntry) => dictEntry[1] === keyEventType);
  }
}

export default ActionResolver;