diff --git a/package-lock.json b/package-lock.json index 3d64add44978..8718532d9388 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38710,6 +38710,11 @@ "dev": true, "optional": true }, + "shim-keyboard-event-key": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/shim-keyboard-event-key/-/shim-keyboard-event-key-1.0.3.tgz", + "integrity": "sha512-PTNRkOxDlZ2+Xz4CbKJJsh/pe1DJdaC+b4HHV02A1aEWNmwh1g9am0ZiU/ktu3uVfQrY3yDHTOVhst3xpLhw2A==" + }, "side-channel": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.3.tgz", diff --git a/package.json b/package.json index bc9856cb20e2..4284ac63a39c 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "react-web-config": "^1.0.0", "rn-fetch-blob": "^0.12.0", "save": "^2.4.0", + "shim-keyboard-event-key": "^1.0.3", "smoothscroll-polyfill": "^0.4.4", "underscore": "^1.13.1", "urbanairship-react-native": "^11.0.2" diff --git a/src/CONST.js b/src/CONST.js index d340757cd11e..813436c60526 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -134,7 +134,7 @@ const CONST = { WEB: 'web', DESKTOP: 'desktop', }, - KEYBOARD_SHORTCUT_MODIFIERS: { + PLATFORM_SPECIFIC_KEYS: { CTRL: { DEFAULT: 'control', [PLATFORM_OS_MACOS]: 'meta', @@ -176,8 +176,9 @@ const CONST = { }, }, KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME: { - CONTROL: 'Ctrl', - META: 'Cmd', + CONTROL: 'CTRL', + ESCAPE: 'ESC', + META: 'CMD', SHIFT: 'Shift', }, CURRENCY: { diff --git a/src/components/Button.js b/src/components/Button.js index 6c826576c692..a57a68a1d50d 100644 --- a/src/components/Button.js +++ b/src/components/Button.js @@ -48,6 +48,9 @@ const propTypes = { /** Call the onPress function when Enter key is pressed */ pressOnEnter: PropTypes.bool, + /** The priority to assign the enter key event listener. 0 is the highest priority. */ + enterKeyEventListenerPriority: PropTypes.number, + /** Additional styles to add after local styles. Applied to Pressable portion of button */ style: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.object), @@ -92,6 +95,7 @@ const defaultProps = { onPressIn: () => {}, onPressOut: () => {}, pressOnEnter: false, + enterKeyEventListenerPriority: 0, style: [], innerStyles: [], textStyles: [], @@ -124,7 +128,7 @@ class Button extends Component { return; } this.props.onPress(); - }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true, false, this.props.enterKeyEventListenerPriority); } componentWillUnmount() { diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js index f86216d1f9bb..7437d187d233 100644 --- a/src/components/KeyboardShortcutsModal.js +++ b/src/components/KeyboardShortcutsModal.js @@ -33,10 +33,9 @@ const defaultProps = { class KeyboardShortcutsModal extends React.Component { componentDidMount() { const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL; - const shortcutModifiers = KeyboardShortcut.getShortcutModifiers(shortcutConfig.modifiers); this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => { KeyboardShortcutsActions.showKeyboardShortcutModal(); - }, shortcutConfig.descriptionKey, shortcutModifiers, true); + }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true); } componentWillUnmount() { @@ -73,7 +72,7 @@ class KeyboardShortcutsModal extends React.Component { } render() { - const shortcuts = KeyboardShortcut.getKeyboardShortcuts(); + const shortcuts = KeyboardShortcut.getDocumentedShortcuts(); const modalType = this.props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE; return ( diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js index 8bb508a3e493..aa485ffa8cd6 100644 --- a/src/libs/KeyboardShortcut/index.js +++ b/src/libs/KeyboardShortcut/index.js @@ -1,72 +1,76 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; +import Str from 'expensify-common/lib/str'; import getOperatingSystem from '../getOperatingSystem'; import CONST from '../../CONST'; -const events = {}; -const keyboardShortcutMap = {}; +// Handlers for the various keyboard listeners we set up +const eventHandlers = {}; + +// Documentation information for keyboard shortcuts that are displayed in the keyboard shortcuts informational modal +const documentedShortcuts = {}; /** - * Return the key-value pair for shortcut keys and translate keys * @returns {Array} */ -function getKeyboardShortcuts() { - return _.values(keyboardShortcutMap); +function getDocumentedShortcuts() { + return _.values(documentedShortcuts); } /** - * Checks if an event for that key is configured and if so, runs it. + * Gets modifiers from a keyboard event. + * * @param {Event} event - * @private + * @returns {Array} */ -function bindHandlerToKeyupEvent(event) { - if (events[event.keyCode] === undefined) { - return; +function getKeyEventModifiers(event) { + const modifiers = []; + if (event.shiftKey) { + modifiers.push('SHIFT'); } + if (event.ctrlKey) { + modifiers.push('CONTROL'); + } + if (event.altKey) { + modifiers.push('ALT'); + } + if (event.metaKey) { + modifiers.push('META'); + } + return modifiers; +} - const eventCallbacks = events[event.keyCode]; - const reversedEventCallbacks = [...eventCallbacks].reverse(); +/** + * Generates the normalized display name for keyboard shortcuts. + * + * @param {String} key + * @param {String|Array} modifiers + * @returns {String} + */ +function getDisplayName(key, modifiers) { + let displayName = [key.toUpperCase()]; + if (_.isString(modifiers)) { + displayName.unshift(modifiers); + } else if (_.isArray(modifiers)) { + displayName = [..._.sortBy(modifiers), ...displayName]; + } - // Loop over all the callbacks - _.every(reversedEventCallbacks, (callback) => { - const pressedModifiers = _.all(callback.modifiers, (modifier) => { - if (modifier === 'shift' && !event.shiftKey) { - return false; - } - if (modifier === 'control' && !event.ctrlKey) { - return false; - } - if (modifier === 'alt' && !event.altKey) { - return false; - } - if (modifier === 'meta' && !event.metaKey) { - return false; - } - return true; - }); - - const extraModifiers = _.difference(['shift', 'control', 'alt', 'meta'], callback.modifiers); - - // returns true if extra modifiers are pressed - const pressedExtraModifiers = _.some(extraModifiers, (extraModifier) => { - if (extraModifier === 'shift' && event.shiftKey) { - return true; - } - if (extraModifier === 'control' && event.ctrlKey) { - return true; - } - if (extraModifier === 'alt' && event.altKey) { - return true; - } - if (extraModifier === 'meta' && event.metaKey) { - return true; - } - return false; - }); - if (!pressedModifiers || pressedExtraModifiers) { - return true; - } + displayName = _.map(displayName, modifier => lodashGet(CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME, modifier.toUpperCase(), modifier)); + return displayName.join(' + '); +} + +/** + * Checks if an event for that key is configured and if so, runs it. + * @param {Event} event + * @private + */ +function bindHandlerToKeydownEvent(event) { + const eventModifiers = getKeyEventModifiers(event); + const displayName = getDisplayName(event.key, eventModifiers); + + // Loop over all the callbacks + _.every(eventHandlers[displayName], (callback) => { // If configured to do so, prevent input text control to trigger this event if (!callback.captureOnInputs && ( event.target.nodeName === 'INPUT' @@ -81,68 +85,46 @@ function bindHandlerToKeyupEvent(event) { } event.preventDefault(); - // Short circuit the loop because the event is triggered - return false; + // If the event should not bubble, short-circuit the loop + let shouldBubble = callback.shouldBubble || false; + if (_.isFunction(callback.shouldBubble)) { + shouldBubble = callback.shouldBubble(); + } + return shouldBubble; }); } // Make sure we don't add multiple listeners -document.removeEventListener('keydown', bindHandlerToKeyupEvent, {capture: true}); -document.addEventListener('keydown', bindHandlerToKeyupEvent, {capture: true}); - -/** - * Returns keyCode for a given key - * @param {String} key The key to watch, i.e. 'K' or 'Escape' - * @returns {Number} The key's keyCode, i.e. 75 or 27 - * @private - */ -function getKeyCode(key) { - // For keys that have longer names we must catch and return the correct key key.charCodeAt(0) would return the - // key code for 'E' (the letter at index 0 in the string) not 'Escape' - switch (key) { - case 'Enter': - return 13; - case 'Escape': - return 27; - default: - return key.charCodeAt(0); - } -} +document.removeEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); +document.addEventListener('keydown', bindHandlerToKeydownEvent, {capture: true}); /** - * Unsubscribes to a keyboard event. - * @param {Number} key The key to stop watching + * Unsubscribes a keyboard event handler. + * + * @param {String} displayName The display name for the key combo to stop watching + * @param {String} callbackID The specific ID given to the callback at the time it was added * @private */ -function unsubscribe(key) { - const keyCode = getKeyCode(key); - events[keyCode].pop(); +function unsubscribe(displayName, callbackID) { + eventHandlers[displayName] = _.reject(eventHandlers[displayName], callback => callback.id === callbackID); } /** - * Add key to the shortcut map + * Return platform specific modifiers for keys like Control (CMD on macOS) * - * @param {String} key The key to watch, i.e. 'K' or 'Escape' - * @param {String|String[]} modifiers Can either be shift or control - * @param {String} descriptionKey Translation key for shortcut description + * @param {Array} keys + * @returns {Array} */ -function addKeyToMap(key, modifiers, descriptionKey) { - let displayName = [key]; - if (_.isString(modifiers)) { - displayName.unshift(modifiers); - } else if (_.isArray(modifiers)) { - displayName = [...modifiers, ...displayName]; - } - - displayName = _.map(displayName, modifier => lodashGet(CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME, modifier.toUpperCase(), modifier)); +function getPlatformEquivalentForKeys(keys) { + const operatingSystem = getOperatingSystem(); + return _.map(keys, (key) => { + if (!_.has(CONST.PLATFORM_SPECIFIC_KEYS, key)) { + return key; + } - displayName = displayName.join(' + '); - keyboardShortcutMap[displayName] = { - shortcutKey: key, - descriptionKey, - displayName, - modifiers, - }; + const platformModifiers = CONST.PLATFORM_SPECIFIC_KEYS[key]; + return lodashGet(platformModifiers, operatingSystem, platformModifiers.DEFAULT || key); + }); } /** @@ -150,55 +132,57 @@ function addKeyToMap(key, modifiers, descriptionKey) { * @param {String} key The key to watch, i.e. 'K' or 'Escape' * @param {Function} callback The callback to call * @param {String} descriptionKey Translation key for shortcut description - * @param {String|Array} modifiers Can either be shift or control - * @param {Boolean} captureOnInputs Should we capture the event on inputs too? + * @param {String|Array} [modifiers] Can either be shift or control + * @param {Boolean} [captureOnInputs] Should we capture the event on inputs too? + * @param {Boolean|Function} [shouldBubble] Should the event bubble? + * @param {Number} [priority] The position the callback should take in the stack. 0 means top priority, and 1 means less priority than the most recently added. * @returns {Function} clean up method */ -function subscribe(key, callback, descriptionKey, modifiers = 'shift', captureOnInputs = false) { - const keyCode = getKeyCode(key); - if (events[keyCode] === undefined) { - events[keyCode] = []; +function subscribe(key, callback, descriptionKey, modifiers = 'shift', captureOnInputs = false, shouldBubble = false, priority = 0) { + const platformAdjustedModifiers = getPlatformEquivalentForKeys(modifiers); + const displayName = getDisplayName(key, platformAdjustedModifiers); + if (!_.has(eventHandlers, displayName)) { + eventHandlers[displayName] = []; } - events[keyCode].push({callback, modifiers: _.isArray(modifiers) ? modifiers : [modifiers], captureOnInputs}); + + const callbackID = Str.guid(); + eventHandlers[displayName].splice(priority, 0, { + id: callbackID, + callback, + captureOnInputs, + shouldBubble, + }); if (descriptionKey) { - addKeyToMap(key, modifiers, descriptionKey); + documentedShortcuts[displayName] = { + shortcutKey: key, + descriptionKey, + displayName, + modifiers, + }; } - return () => unsubscribe(key); -} -/** - * Return platform specific modifiers for keys like Control (Cmd) - * @param {Array} modifiers - * @returns {Array} - */ -function getShortcutModifiers(modifiers) { - const operatingSystem = getOperatingSystem(); - return _.map(modifiers, (modifier) => { - if (!_.has(CONST.KEYBOARD_SHORTCUT_MODIFIERS, modifier)) { - return modifier; - } - - const platformModifiers = CONST.KEYBOARD_SHORTCUT_MODIFIERS[modifier]; - return lodashGet(platformModifiers, operatingSystem, platformModifiers.DEFAULT || modifier); - }); + return () => unsubscribe(displayName, callbackID); } /** - * Module storing the different keyboard shortcut + * This module configures a global keyboard event handler. + * + * It uses a stack to store event handlers for each key combination. Some additional details: + * + * - By default, new handlers are pushed to the top of the stack. If you pass a >0 priority when subscribing to the key event, + * then the handler will get pushed further down the stack. This means that priority of 0 is higher than priority 1. * - * We are using a push/pop model where new event are pushed at the end of an - * array of events. When the event occur, we trigger the callback of the last - * element. This allow us to replace shortcut from a page to a dialog without - * having the page having to handle that logic. + * - When a key event occurs, we trigger callbacks for that key starting from the top of the stack. + * By default, events do not bubble, and only the handler at the top of the stack will be executed. + * Individual callbacks can be configured with the shouldBubble parameter, to allow the next event handler on the stack execute. * - * This is also following the convention of the PubSub module. - * The "subClass" is used by pages to bind /unbind with no worries + * - Each handler has a unique callbackID, so calling the `unsubscribe` function (returned from `subscribe`) will unsubscribe the expected handler, + * regardless of its position in the stack. */ const KeyboardShortcut = { subscribe, - getKeyboardShortcuts, - getShortcutModifiers, + getDocumentedShortcuts, }; export default KeyboardShortcut; diff --git a/src/libs/KeyboardShortcut/index.native.js b/src/libs/KeyboardShortcut/index.native.js index ae1d49830dce..8c97f2daf343 100644 --- a/src/libs/KeyboardShortcut/index.native.js +++ b/src/libs/KeyboardShortcut/index.native.js @@ -6,8 +6,7 @@ const KeyboardShortcut = { subscribe() { return () => {}; }, - getKeyboardShortcuts() { return []; }, - getShortcutModifiers() { return []; }, + getDocumentedShortcuts() { return []; }, }; export default KeyboardShortcut; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 5b5d72713217..944dcfa52e97 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -153,20 +153,17 @@ class AuthScreens extends React.Component { Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER); const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH; - const searchShortcutModifiers = KeyboardShortcut.getShortcutModifiers(searchShortcutConfig.modifiers); - const groupShortcutConfig = CONST.KEYBOARD_SHORTCUTS.NEW_GROUP; - const groupShortcutModifiers = KeyboardShortcut.getShortcutModifiers(groupShortcutConfig.modifiers); // Listen for the key K being pressed so that focus can be given to // the chat switcher, or new group chat // based on the key modifiers pressed and the operating system this.unsubscribeSearchShortcut = KeyboardShortcut.subscribe(searchShortcutConfig.shortcutKey, () => { Navigation.navigate(ROUTES.SEARCH); - }, searchShortcutConfig.descriptionKey, searchShortcutModifiers, true); + }, searchShortcutConfig.descriptionKey, searchShortcutConfig.modifiers, true); this.unsubscribeGroupShortcut = KeyboardShortcut.subscribe(groupShortcutConfig.shortcutKey, () => { Navigation.navigate(ROUTES.NEW_GROUP); - }, groupShortcutConfig.descriptionKey, groupShortcutModifiers, true); + }, groupShortcutConfig.descriptionKey, groupShortcutConfig.modifiers, true); } shouldComponentUpdate(nextProps) { diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js index a92e335a1838..ff0bc8d3f62f 100755 --- a/src/pages/home/report/ReportActionsView.js +++ b/src/pages/home/report/ReportActionsView.js @@ -151,11 +151,9 @@ class ReportActionsView extends React.Component { Report.fetchActions(this.props.reportID); const copyShortcutConfig = CONST.KEYBOARD_SHORTCUTS.COPY; - const copyShortcutModifiers = KeyboardShortcut.getShortcutModifiers(copyShortcutConfig.modifiers); - this.unsubscribeCopyShortcut = KeyboardShortcut.subscribe(copyShortcutConfig.shortcutKey, () => { this.copySelectionToClipboard(); - }, copyShortcutConfig.descriptionKey, copyShortcutModifiers, false); + }, copyShortcutConfig.descriptionKey, copyShortcutConfig.modifiers, false); } shouldComponentUpdate(nextProps, nextState) { diff --git a/src/setup/platformSetup/index.website.js b/src/setup/platformSetup/index.website.js index 2913d8ea52ea..e8e9c759b100 100644 --- a/src/setup/platformSetup/index.website.js +++ b/src/setup/platformSetup/index.website.js @@ -1,4 +1,8 @@ import {AppRegistry} from 'react-native'; + +// This is a polyfill for InternetExplorer to support the modern KeyboardEvent.key and KeyboardEvent.code instead of KeyboardEvent.keyCode +import 'shim-keyboard-event-key'; + import checkForUpdates from '../../libs/checkForUpdates'; import Config from '../../CONFIG'; import HttpUtils from '../../libs/HttpUtils';