streetmix/streetmix

View on GitHub
assets/scripts/app/keypress.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * keypress
 *
 * Handles registration, removal, and processing of keyboard inputs.
 * Some inspiration:
 *    - Mousetrap.js (https://craig.is/killing/mice)
 *    - Keypress.js (http://dmauro.github.io/Keypress/)
 *    - keymaster.js (https://github.com/madrobby/keymaster)
 *
 * @module keypress
 * @requires event-tracking
 * @exports startListening
 * @exports registerKeypress
 * @exports deregisterKeypress
 */
import { isFocusOnBody } from '../util/focus'

// Keep track of all registered commands here
const inputs = {}

// Utility functions
const noop = function () {}
const returnTrue = function () {
  return true
}

/**
 * Initiates keypress manager. Sets a global event listener on the window.
 * This should only have to be called once, when the application bootstraps.
 *
 * @public
 */
export function startListening () {
  window.addEventListener('keydown', onGlobalKeyDown)
}

/**
 * Registers a key command listener with the Keypress Manager
 *
 * @public
 * @example
 *    registerKeypress('esc', hide)
 * @example
 *    registerKeypress('shift d',
 *      { preventDefault: true },
 *      function () { console.log('Shift-D is pressed!') })
 * @param {(string|string[])} commands
 *    Human readable key or key combination to listen for, in the form of "a"
 *    or "shift a" or "control alt a". If multiple keys should perform the
 *    same action, pass in an array of strings, e.g. `['a', 'b', 'meta d']`
 * @param {object} [options]
 *    Options that fine tune the behavior of the keypress. If you are unhappy
 *    with a default setting, they can be overridden here. Note that it is
 *    possible for overrides to conflict with other parameters, for instance,
 *    if you set `commands` to "shift p" but `options.shiftKey` to `false`.
 *    Don't do this. This is confusing and I won't guarantee preserving how
 *    this conflict is addressed.
 * @param {(boolean|string)} [options.shiftKey=false]
 *    If `true`, the `Shift` key should be pressed in a key combination. This
 *    is automatically set to `true` if `shift` is specified in the `commands`
 *    parameter. The boolean test is strict. The string 'optional' can be
 *    passed so that the key will fire regardless of whether `Shift` is
 *    pressed.
 * @param {(boolean|string)} [options.metaKey=false]
 *    If `true`, the "meta" key (`Command` or `Control`, depending on the OS)
 *    should be pressed in a key combination. This is automatically set to
 *    `true` if `meta`, `control`, or `command` is specified in the `commands`
 *    parameter. The boolean test is strict. The string 'optional' can be
 *    passed so that the key will fire regardless of whether `Command` or
 *    `Control` is pressed.
 * @param {(boolean|string)} [options.altKey=false]
 *    If `true`, the `Alt` key should be pressed in a key combination. This
 *    is automatically set to `true` if `alt` is specified in the `commands`
 *    parameter. The boolean test is strict. The string 'optional' can be
 *    passed so that the key will fire regardless of whether `Alt` is pressed.
 * @param {(boolean)} [options.preventDefault=true]
 *    If `true`, `event.preventDefault()` should be called to prevent the
 *    key's default behavior.
 * @param {(boolean)} [options.stopPropagation=false]
 *    If `true`, `event.stopPropagation()` should be called to prevent other
 *    key handlers from triggering. Defaults to `false`, but as a special
 *    case, this is automatically set to `true` if the command is `esc`.
 * @param {(boolean)} [options.requireFocusOnBody=true]
 *    If `true`, the key handler is not triggered if the browser has focused
 *    on a specific element, like an input field.  Defaults to `true`, but as
 *    a special case, this is automatically set to `false` if the command is
 *    `esc`.
 * @param {(function)} [options.onKeyPress]
 *    It is possible to set the callback function to execute on key press on
 *    the `options` object instead of in the `callback` parameter.
 * @param {(function)} [options.condition]
 *    If additional conditions are required before executing a callback
 *    function after a key is pressed, this function is evaluated. It must
 *    return a `true` or truthy value for the callback to execute.
 * @param {boolean} [options.fireOnce]
 *    If `true`, the keypress is only activated one time and is immediately
 *    deregistered when the callback is executed.
 * @param {function} [callback]
 *    Function to execute when key is pressed. Technically, this is optional
 *    (and you might prefer to set it on `options` instead). If there is no
 *    callback function the keypress simply does nothing.
 */
export function registerKeypress (commands, options, callback) {
  // Defaults
  // For shiftKey, metaKey, and altKey, specifies what it should
  // match on the event object reported by the browser. For instance,
  // if event.metaKey is false, it means the command must be executed
  // only if the meta key (ctrl or command depending on the OS) is not
  // pressed. (Note that ctrlKey will be internally mapped to behave
  // the same as metaKey here.) The distinction is strict, pass the
  // value 'optional' to make the system ignore whether a key is pressed.
  const defaults = {
    shiftKey: false,
    metaKey: false,
    altKey: false,
    onKeypress: noop,
    condition: returnTrue,
    preventDefault: true,
    stopPropagation: false,
    requireFocusOnBody: true,
    fireOnce: false
  }

  // Check if the second argument is the options object or the callback function
  if (typeof arguments[1] === 'object') {
    options = arguments[1]
  } else if (typeof arguments[1] === 'function') {
    // The second argument is the callback function
    // You cannot pass two callback functions
    options = {}
    callback = arguments[1]
  }

  const originalCommands = commands
  const commandObj = processCommands(commands)

  // Process each command input
  for (const key in commandObj) {
    const commands = commandObj[key]

    for (const command of commands) {
      // Get default settings
      for (const keyDefault in defaults) {
        if (typeof command[keyDefault] === 'undefined') {
          command[keyDefault] = defaults[keyDefault]
        }
      }

      // Store the original commands entry
      // This helps with deregistering later
      command.originalCommands = originalCommands

      // Special case for 'ESC' key; it defaults to global (window) focus
      if (key === 'Esc' || key === 'Escape') {
        command.requireFocusOnBody = false
        command.stopPropagation = true
      }

      // Attach callback function to execute
      if (typeof callback === 'function') {
        command.onKeypress = callback
      }

      // If options are specified, replace defaults
      // This basically allows other code to override settings via the
      // options object - it's dumb, but there's no protection against it,
      // and who knows, could be useful in edge cases
      for (const k in options) {
        command[k] = options[k]
      }

      // Add processed commands to module's inputs holder
      if (typeof inputs[key] === 'undefined') {
        inputs[key] = []
      }
      inputs[key].push(command)
    }
  }
}

/**
 * Deregisters a key command listener, given matching `commands` and
 * `callback` parameters.
 *
 * @public
 * @example Deregisters all triggers for `shift d`
 *    deregisterKeypress('shift d')
 * @example Deregisters triggers matching callback function `hide` and key `esc`
 *    deregisterKeypress('esc', hide)
 * @param {(string|string[])} commands
 *    Human readable key or key combination to listen for, in the form of "a"
 *    or "shift a" or "control alt a". If multiple keys should perform the
 *    same action, pass in an array of strings, e.g. `['a', 'b', 'meta d']`
 * @param {function} [callback]
 *    Callback function to execute when key is pressed. This is if you want
 *    to only remove key handlers that match the same callback. If you do not
 *    provide a callback, all handlers that match `commands` are removed.
 * @todo Because of how function equality works, not all functions passed
 *    in this way result in a true test of equality.
 */
export function deregisterKeypress (commands, callback) {
  const commandObj = processCommands(commands)

  // Process each command input
  for (const key in commandObj) {
    const commands = commandObj[key]

    for (const command of commands) {
      const items = inputs[key]
      let x = (items && items.length) || 0

      // Break if the derigestered command is not found (may have not been registered,
      // or already deregistered)
      if (x === 0) break

      // A reverse while loop quickly removes all duplicates that matches
      while (x--) {
        const item = items[x]
        if (item.onKeypress === callback || typeof callback === 'undefined') {
          // Check for equality for command + function
          const isShiftOrOptional =
            item.shiftKey === command.shiftKey || item.shiftKey === 'optional'
          const isAltOrOptional =
            item.altKey === command.altKey || item.altKey === 'optional'
          const isMetaOrOptional =
            item.metaKey === command.metaKey || item.metaKey === 'optional'
          if (isShiftOrOptional && isAltOrOptional && isMetaOrOptional) {
            // If matches, remove it from the command list.
            inputs[key].splice(x, 1)
          }
        }
      }
    }
  }
}

/**
 * Processes commands
 *
 * @private
 * @param {(string|string[])} commands
 *    Human readable key or key combination to listen for, in the form of "a"
 *    or "shift a" or "control alt a". If multiple keys should perform the
 *    same action, pass in an array of strings, e.g. `['a', 'b', 'meta d']`
 * @returns object
 */
function processCommands (commands) {
  // If a string, force to one-element array, otherwise expect an array of strings
  if (typeof commands === 'string') {
    commands = new Array(commands)
  }

  const commandsObj = {}

  // Process each command input
  for (let command of commands) {
    // Normalize command
    //  - adjust to lower case
    //  - normalize 'esc' to 'escape'
    //  - replace command/cmd/control/ctrl to meta (this does not remove dupes)
    command = command
      .toLowerCase()
      .replace(/(command|cmd|control|ctrl)/g, 'meta')
      .split(' ')

    const settings = {
      shiftKey: false,
      altKey: false,
      metaKey: false
    }

    // Check for existence of modifier keys
    // Modifier keys are removed from input array
    const isShift = command.indexOf('shift')
    if (isShift > -1) {
      settings.shiftKey = true
      command.splice(isShift, 1)
    }

    const isAlt = command.indexOf('alt')
    if (isAlt > -1) {
      settings.altKey = true
      command.splice(isAlt, 1)
    }

    const isMeta = command.indexOf('meta')
    if (isMeta > -1) {
      settings.metaKey = true
      command.splice(isMeta, 1)
    }

    // First remaining item in the input array is the key to test for.
    // Does not support multi-keys, so rest of input (if provided) is ignored.
    const key = command[0]

    // key might be a single string or an array
    if (key) {
      let keys = []
      // If key is a string, convert to single-element array
      // Can't do a shortcut version of these with string :-/
      if (typeof key === 'string') {
        keys.push(key)
      } else if (Array.isArray(key)) {
        keys = key
      }

      for (const key of keys) {
        let processedKey
        switch (key) {
          case 'esc':
            processedKey = 'escape'
            break
          case 'left':
            processedKey = 'arrowleft'
            break
          case 'right':
            processedKey = 'arrowright'
            break
          default:
            processedKey = key
            break
        }

        settings.key = processedKey

        if (typeof commandsObj[processedKey] === 'undefined') {
          commandsObj[processedKey] = []
        }

        commandsObj[processedKey].push(settings)
      }
    }
  }

  return commandsObj
}

function onGlobalKeyDown (event) {
  const toExecute = []
  const key = event.key

  // There is a bug in Chrome where events can be fired with an
  // undefined `key` property, which does not adhere to spec.
  // This can be duplicated by autofilling an input with 1Password,
  // and it may be caused by other tools as well.
  if (typeof key === 'undefined') return

  // Find the right command object
  const commandsForKey = inputs[key.toLowerCase()]
  if (!commandsForKey || commandsForKey.length === 0) return

  // Check if the right meta keys are down
  for (const item of commandsForKey) {
    if (
      (item.shiftKey === event.shiftKey || item.shiftKey === 'optional') &&
      (item.altKey === event.altKey || item.altKey === 'optional') &&
      (item.metaKey === event.metaKey ||
        item.metaKey === event.ctrlKey ||
        item.metaKey === 'optional')
    ) {
      toExecute.push(item)
    }
  }

  // Execute input's callbacks, if found
  for (const input of toExecute) {
    execute(input, event)
  }
}

/**
 * Executes an input's callback function
 *
 * @private
 * @param {object} input - The input object to execute
 * @param {Event} [event] - The browser's `Event` object created when `keydown` is fired
 */
function execute (input, event) {
  // Check if condition is satisfied
  if (!input.condition()) return

  // Check if focus is on the correct place
  if (input.requireFocusOnBody === true && isFocusOnBody() === false) return

  if (event && input.preventDefault) {
    event.preventDefault()
  }
  if (event && input.stopPropagation) {
    event.stopPropagation()
  }

  // Execute callback
  // Pass event through to callback function
  input.onKeypress(event)

  // Deregisters the input immediately if it is only supposed to be executed once
  if (input.fireOnce === true) {
    deregisterKeypress(input.originalCommands, input.onKeypress)
  }
}