diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.js b/src/libs/KeyboardShortcut/KeyDownPressListener/index.js deleted file mode 100644 index 4401beef1c59b..0000000000000 --- a/src/libs/KeyboardShortcut/KeyDownPressListener/index.js +++ /dev/null @@ -1,9 +0,0 @@ -function addKeyDownPressListner(callbackFunction) { - document.addEventListener('keydown', callbackFunction); -} - -function removeKeyDownPressListner(callbackFunction) { - document.removeEventListener('keydown', callbackFunction); -} - -export {addKeyDownPressListner, removeKeyDownPressListner}; diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js b/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js deleted file mode 100644 index aa1ded824d225..0000000000000 --- a/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.js +++ /dev/null @@ -1,4 +0,0 @@ -function addKeyDownPressListner() {} -function removeKeyDownPressListner() {} - -export {addKeyDownPressListner, removeKeyDownPressListner}; diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.ts b/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.ts new file mode 100644 index 0000000000000..8b460a069f05e --- /dev/null +++ b/src/libs/KeyboardShortcut/KeyDownPressListener/index.native.ts @@ -0,0 +1,6 @@ +import {AddKeyDownPressListener, RemoveKeyDownPressListener} from './types'; + +const addKeyDownPressListener: AddKeyDownPressListener = () => {}; +const removeKeyDownPressListener: RemoveKeyDownPressListener = () => {}; + +export {addKeyDownPressListener, removeKeyDownPressListener}; diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/index.ts b/src/libs/KeyboardShortcut/KeyDownPressListener/index.ts new file mode 100644 index 0000000000000..7e2b2a2ce3190 --- /dev/null +++ b/src/libs/KeyboardShortcut/KeyDownPressListener/index.ts @@ -0,0 +1,11 @@ +import type {AddKeyDownPressListener, RemoveKeyDownPressListener} from './types'; + +const addKeyDownPressListener: AddKeyDownPressListener = (callbackFunction) => { + document.addEventListener('keydown', callbackFunction); +}; + +const removeKeyDownPressListener: RemoveKeyDownPressListener = (callbackFunction) => { + document.removeEventListener('keydown', callbackFunction); +}; + +export {addKeyDownPressListener, removeKeyDownPressListener}; diff --git a/src/libs/KeyboardShortcut/KeyDownPressListener/types.ts b/src/libs/KeyboardShortcut/KeyDownPressListener/types.ts new file mode 100644 index 0000000000000..1e36051a794df --- /dev/null +++ b/src/libs/KeyboardShortcut/KeyDownPressListener/types.ts @@ -0,0 +1,6 @@ +type KeyDownPressCallback = (event: KeyboardEvent) => void; + +type AddKeyDownPressListener = (callbackFunction: KeyDownPressCallback) => void; +type RemoveKeyDownPressListener = (callbackFunction: KeyDownPressCallback) => void; + +export type {AddKeyDownPressListener, RemoveKeyDownPressListener}; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js deleted file mode 100644 index de59c819c504a..0000000000000 --- a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.js +++ /dev/null @@ -1,33 +0,0 @@ -import _ from 'underscore'; -import getKeyEventModifiers from '../getKeyEventModifiers'; - -/** - * Checks if an event for that key is configured and if so, runs it. - * @param {Function} getDisplayName - * @param {Object} eventHandlers - * @param {Object} keycommandEvent - * @param {Event} event - * @private - */ -function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { - const eventModifiers = getKeyEventModifiers(keycommandEvent); - const displayName = getDisplayName(keycommandEvent.input, eventModifiers); - - // Loop over all the callbacks - _.every(eventHandlers[displayName], (callback) => { - // Determine if the event should bubble before executing the callback (which may have side-effects) - let shouldBubble = callback.shouldBubble || false; - if (_.isFunction(callback.shouldBubble)) { - shouldBubble = callback.shouldBubble(); - } - - if (_.isFunction(callback.callback)) { - callback.callback(event); - } - - // If the event should not bubble, short-circuit the loop - return shouldBubble; - }); -} - -export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts new file mode 100644 index 0000000000000..d23d558fa1f8a --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.native.ts @@ -0,0 +1,28 @@ +import getKeyEventModifiers from '../getKeyEventModifiers'; +import BindHandlerToKeydownEvent from './types'; + +/** + * Checks if an event for that key is configured and if so, runs it. + */ +const bindHandlerToKeydownEvent: BindHandlerToKeydownEvent = (getDisplayName, eventHandlers, keyCommandEvent, event) => { + const eventModifiers = getKeyEventModifiers(keyCommandEvent); + const displayName = getDisplayName(keyCommandEvent.input, eventModifiers); + + // Loop over all the callbacks + Object.values(eventHandlers[displayName]).every((callback) => { + // Determine if the event should bubble before executing the callback (which may have side-effects) + let shouldBubble: boolean | (() => void) | void = callback.shouldBubble || false; + if (typeof callback.shouldBubble === 'function') { + shouldBubble = callback.shouldBubble(); + } + + if (typeof callback.callback === 'function') { + callback.callback(event); + } + + // If the event should not bubble, short-circuit the loop + return shouldBubble; + }); +}; + +export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts similarity index 52% rename from src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js rename to src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts index 7b1cb00a408b0..8f06c2fe9c2fd 100644 --- a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.js +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/index.ts @@ -1,44 +1,44 @@ -import _ from 'underscore'; import getKeyEventModifiers from '../getKeyEventModifiers'; import isEnterWhileComposition from '../isEnterWhileComposition'; +import BindHandlerToKeydownEvent from './types'; /** * Checks if an event for that key is configured and if so, runs it. - * @param {Function} getDisplayName - * @param {Object} eventHandlers - * @param {Object} keycommandEvent - * @param {Event} event - * @private */ -function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event) { +const bindHandlerToKeydownEvent: BindHandlerToKeydownEvent = (getDisplayName, eventHandlers, keyCommandEvent, event) => { if (!(event instanceof KeyboardEvent) || isEnterWhileComposition(event)) { return; } - const eventModifiers = getKeyEventModifiers(keycommandEvent); - const displayName = getDisplayName(keycommandEvent.input, eventModifiers); + const eventModifiers = getKeyEventModifiers(keyCommandEvent); + const displayName = getDisplayName(keyCommandEvent.input, eventModifiers); // Loop over all the callbacks - _.every(eventHandlers[displayName], (callback) => { + Object.values(eventHandlers[displayName]).every((callback) => { + const textArea = event.target as HTMLTextAreaElement; + const contentEditable = textArea?.contentEditable; + const nodeName = textArea?.nodeName; + // Early return for excludedNodes - if (_.contains(callback.excludedNodes, event.target.nodeName)) { + if (callback.excludedNodes.includes(nodeName)) { return true; } // If configured to do so, prevent input text control to trigger this event - if (!callback.captureOnInputs && (event.target.nodeName === 'INPUT' || event.target.nodeName === 'TEXTAREA' || event.target.contentEditable === 'true')) { + if (!callback.captureOnInputs && (nodeName === 'INPUT' || nodeName === 'TEXTAREA' || contentEditable === 'true')) { return true; } // Determine if the event should bubble before executing the callback (which may have side-effects) - let shouldBubble = callback.shouldBubble || false; - if (_.isFunction(callback.shouldBubble)) { + let shouldBubble: boolean | (() => void) | void = callback.shouldBubble || false; + if (typeof callback.shouldBubble === 'function') { shouldBubble = callback.shouldBubble(); } - if (_.isFunction(callback.callback)) { + if (typeof callback.callback === 'function') { callback.callback(event); } + if (callback.shouldPreventDefault) { event.preventDefault(); } @@ -46,6 +46,6 @@ function bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEven // If the event should not bubble, short-circuit the loop return shouldBubble; }); -} +}; export default bindHandlerToKeydownEvent; diff --git a/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/types.ts b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/types.ts new file mode 100644 index 0000000000000..6a9aefb30676d --- /dev/null +++ b/src/libs/KeyboardShortcut/bindHandlerToKeydownEvent/types.ts @@ -0,0 +1,11 @@ +import type {EventHandler} from '../index'; + +type KeyCommandEvent = {input: string; modifierFlags?: string}; + +type GetDisplayName = (key: string, modifiers: string | string[]) => string; + +type BindHandlerToKeydownEvent = (getDisplayName: GetDisplayName, eventHandlers: Record, keyCommandEvent: KeyCommandEvent, event: KeyboardEvent) => void; + +export default BindHandlerToKeydownEvent; + +export type {KeyCommandEvent}; diff --git a/src/libs/KeyboardShortcut/getKeyEventModifiers.js b/src/libs/KeyboardShortcut/getKeyEventModifiers.js deleted file mode 100644 index 7865d51a05079..0000000000000 --- a/src/libs/KeyboardShortcut/getKeyEventModifiers.js +++ /dev/null @@ -1,27 +0,0 @@ -import * as KeyCommand from 'react-native-key-command'; -import lodashGet from 'lodash/get'; - -/** - * Gets modifiers from a keyboard event. - * - * @param {Event} event - * @returns {Array} - */ -function getKeyEventModifiers(event) { - if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierControl', 'keyModifierControl')) { - return ['CONTROL']; - } - if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierCommand', 'keyModifierCommand')) { - return ['META']; - } - if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftControl', 'keyModifierShiftControl')) { - return ['CONTROL', 'Shift']; - } - if (event.modifierFlags === lodashGet(KeyCommand, 'constants.keyModifierShiftCommand', 'keyModifierShiftCommand')) { - return ['META', 'Shift']; - } - - return []; -} - -export default getKeyEventModifiers; diff --git a/src/libs/KeyboardShortcut/getKeyEventModifiers.ts b/src/libs/KeyboardShortcut/getKeyEventModifiers.ts new file mode 100644 index 0000000000000..f82de725bb50e --- /dev/null +++ b/src/libs/KeyboardShortcut/getKeyEventModifiers.ts @@ -0,0 +1,29 @@ +import * as KeyCommand from 'react-native-key-command'; +import {KeyCommandEvent} from './bindHandlerToKeydownEvent/types'; + +const keyModifierControl = KeyCommand?.constants.keyModifierControl ?? 'keyModifierControl'; +const keyModifierCommand = KeyCommand?.constants.keyModifierCommand ?? 'keyModifierCommand'; +const keyModifierShiftControl = KeyCommand?.constants.keyModifierShiftControl ?? 'keyModifierShiftControl'; +const keyModifierShiftCommand = KeyCommand?.constants.keyModifierShiftCommand ?? 'keyModifierShiftCommand'; + +/** + * Gets modifiers from a keyboard event. + */ +function getKeyEventModifiers(event: KeyCommandEvent): string[] { + if (event.modifierFlags === keyModifierControl) { + return ['CONTROL']; + } + if (event.modifierFlags === keyModifierCommand) { + return ['META']; + } + if (event.modifierFlags === keyModifierShiftControl) { + return ['CONTROL', 'Shift']; + } + if (event.modifierFlags === keyModifierShiftCommand) { + return ['META', 'Shift']; + } + + return []; +} + +export default getKeyEventModifiers; diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js deleted file mode 100644 index bce65744801cd..0000000000000 --- a/src/libs/KeyboardShortcut/index.js +++ /dev/null @@ -1,172 +0,0 @@ -import _ from 'underscore'; -import lodashGet from 'lodash/get'; -import Str from 'expensify-common/lib/str'; -import * as KeyCommand from 'react-native-key-command'; -import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent'; -import getOperatingSystem from '../getOperatingSystem'; -import CONST from '../../CONST'; - -const operatingSystem = getOperatingSystem(); - -// 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 = {}; - -/** - * @returns {Array} - */ -function getDocumentedShortcuts() { - return _.sortBy(_.values(documentedShortcuts), 'displayName'); -} - -/** - * Generates the normalized display name for keyboard shortcuts. - * - * @param {String} key - * @param {String|Array} modifiers - * @returns {String} - */ -function getDisplayName(key, modifiers) { - let displayName = (() => { - // Type of key is string and the type of KeyCommand.constants.* is number | string. Use _.isEqual to match different types. - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEnter', 'keyInputEnter').toString().toLowerCase())) { - return ['ENTER']; - } - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputEscape', 'keyInputEscape').toString().toLowerCase())) { - return ['ESCAPE']; - } - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputUpArrow', 'keyInputUpArrow').toString().toLowerCase())) { - return ['ARROWUP']; - } - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputDownArrow', 'keyInputDownArrow').toString().toLowerCase())) { - return ['ARROWDOWN']; - } - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputLeftArrow', 'keyInputLeftArrow').toString().toLowerCase())) { - return ['ARROWLEFT']; - } - if (_.isEqual(key.toLowerCase(), lodashGet(KeyCommand, 'constants.keyInputRightArrow', 'keyInputRightArrow').toString().toLowerCase())) { - return ['ARROWRIGHT']; - } - return [key.toUpperCase()]; - })(); - - if (_.isString(modifiers)) { - displayName.unshift(modifiers); - } else if (_.isArray(modifiers)) { - displayName = [..._.sortBy(modifiers), ...displayName]; - } - - displayName = _.map(displayName, (modifier) => lodashGet(CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME, modifier.toUpperCase(), modifier)); - - return displayName.join(' + '); -} - -_.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => { - const shortcutTrigger = lodashGet(shortcut, ['trigger', operatingSystem], lodashGet(shortcut, 'trigger.DEFAULT')); - - // If there is no trigger for the current OS nor a default trigger, then we don't need to do anything - if (!shortcutTrigger) { - return; - } - - KeyCommand.addListener(shortcutTrigger, (keycommandEvent, event) => bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keycommandEvent, event)); -}); - -/** - * 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(displayName, callbackID) { - eventHandlers[displayName] = _.reject(eventHandlers[displayName], (callback) => callback.id === callbackID); - if (_.has(documentedShortcuts, displayName) && _.size(eventHandlers[displayName]) === 0) { - delete documentedShortcuts[displayName]; - } -} - -/** - * Return platform specific modifiers for keys like Control (CMD on macOS) - * - * @param {Array} keys - * @returns {Array} - */ -function getPlatformEquivalentForKeys(keys) { - return _.map(keys, (key) => { - if (!_.has(CONST.PLATFORM_SPECIFIC_KEYS, key)) { - return key; - } - - const platformModifiers = CONST.PLATFORM_SPECIFIC_KEYS[key]; - return lodashGet(platformModifiers, operatingSystem, platformModifiers.DEFAULT || key); - }); -} - -/** - * Subscribes to a keyboard event. - * @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 {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. - * @param {Boolean} [shouldPreventDefault] Should call event.preventDefault after callback? - * @param {Array} [excludedNodes] Do not capture key events targeting excluded nodes (i.e. do not prevent default and let the event bubble) - * @returns {Function} clean up method - */ -function subscribe(key, callback, descriptionKey, modifiers = 'shift', captureOnInputs = false, shouldBubble = false, priority = 0, shouldPreventDefault = true, excludedNodes = []) { - const platformAdjustedModifiers = getPlatformEquivalentForKeys(modifiers); - const displayName = getDisplayName(key, platformAdjustedModifiers); - if (!_.has(eventHandlers, displayName)) { - eventHandlers[displayName] = []; - } - - const callbackID = Str.guid(); - eventHandlers[displayName].splice(priority, 0, { - id: callbackID, - callback, - captureOnInputs, - shouldPreventDefault, - shouldBubble, - excludedNodes, - }); - - if (descriptionKey) { - documentedShortcuts[displayName] = { - shortcutKey: key, - descriptionKey, - displayName, - modifiers, - }; - } - - return () => unsubscribe(displayName, callbackID); -} - -/** - * 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. - * - * - 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. - * - * - 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, - getDisplayName, - getDocumentedShortcuts, - getPlatformEquivalentForKeys, -}; - -export default KeyboardShortcut; diff --git a/src/libs/KeyboardShortcut/index.ts b/src/libs/KeyboardShortcut/index.ts new file mode 100644 index 0000000000000..3109ccda8aaf4 --- /dev/null +++ b/src/libs/KeyboardShortcut/index.ts @@ -0,0 +1,190 @@ +import Str from 'expensify-common/lib/str'; +import * as KeyCommand from 'react-native-key-command'; +import bindHandlerToKeydownEvent from './bindHandlerToKeydownEvent'; +import getOperatingSystem from '../getOperatingSystem'; +import CONST from '../../CONST'; + +const operatingSystem = getOperatingSystem(); + +type EventHandler = { + id: string; + callback: (event?: KeyboardEvent) => void; + captureOnInputs: boolean; + shouldPreventDefault: boolean; + shouldBubble: boolean | (() => void); + excludedNodes: string[]; +}; + +// Handlers for the various keyboard listeners we set up +const eventHandlers: Record = {}; + +type Shortcut = { + displayName: string; + shortcutKey: string; + descriptionKey: string; + modifiers: string[]; +}; + +// Documentation information for keyboard shortcuts that are displayed in the keyboard shortcuts informational modal +const documentedShortcuts: Record = {}; + +function getDocumentedShortcuts(): Shortcut[] { + return Object.values(documentedShortcuts).sort((a, b) => a.displayName.localeCompare(b.displayName)); +} + +const keyInputEnter = KeyCommand?.constants?.keyInputEnter?.toString() ?? 'keyInputEnter'; +const keyInputEscape = KeyCommand?.constants?.keyInputEscape?.toString() ?? 'keyInputEscape'; +const keyInputUpArrow = KeyCommand?.constants?.keyInputUpArrow?.toString() ?? 'keyInputUpArrow'; +const keyInputDownArrow = KeyCommand?.constants?.keyInputDownArrow?.toString() ?? 'keyInputDownArrow'; +const keyInputLeftArrow = KeyCommand?.constants?.keyInputLeftArrow?.toString() ?? 'keyInputLeftArrow'; +const keyInputRightArrow = KeyCommand?.constants?.keyInputRightArrow?.toString() ?? 'keyInputRightArrow'; + +/** + * Generates the normalized display name for keyboard shortcuts. + */ +function getDisplayName(key: string, modifiers: string | string[]): string { + let displayName = (() => { + // Type of key is string and the type of KeyCommand.constants.* is number | string. + if (key.toLowerCase() === keyInputEnter.toLowerCase()) { + return ['ENTER']; + } + if (key.toLowerCase() === keyInputEscape.toLowerCase()) { + return ['ESCAPE']; + } + if (key.toLowerCase() === keyInputUpArrow.toLowerCase()) { + return ['ARROWUP']; + } + if (key.toLowerCase() === keyInputDownArrow.toLowerCase()) { + return ['ARROWDOWN']; + } + if (key.toLowerCase() === keyInputLeftArrow.toLowerCase()) { + return ['ARROWLEFT']; + } + if (key.toLowerCase() === keyInputRightArrow.toLowerCase()) { + return ['ARROWRIGHT']; + } + return [key.toUpperCase()]; + })(); + + if (typeof modifiers === 'string') { + displayName.unshift(modifiers); + } else if (Array.isArray(modifiers)) { + displayName = [...modifiers.sort(), ...displayName]; + } + + displayName = displayName.map((modifier) => CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME[modifier.toUpperCase() as keyof typeof CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME] ?? modifier); + + return displayName.join(' + '); +} + +Object.values(CONST.KEYBOARD_SHORTCUTS).forEach((shortcut) => { + // If there is no trigger for the current OS nor a default trigger, then we don't need to do anything + if (!('trigger' in shortcut)) { + return; + } + + const shortcutTrigger = (operatingSystem && shortcut.trigger[operatingSystem as keyof typeof shortcut.trigger]) ?? shortcut.trigger.DEFAULT; + + KeyCommand.addListener(shortcutTrigger, (keyCommandEvent, event) => bindHandlerToKeydownEvent(getDisplayName, eventHandlers, keyCommandEvent, event)); +}); + +/** + * Unsubscribes a keyboard event handler. + */ +function unsubscribe(displayName: string, callbackID: string) { + eventHandlers[displayName] = eventHandlers[displayName].filter((callback) => callback.id !== callbackID); + if (eventHandlers[displayName]?.length === 0) { + delete documentedShortcuts[displayName]; + } +} + +/** + * Return platform specific modifiers for keys like Control (CMD on macOS) + */ +function getPlatformEquivalentForKeys(keys: string[]): string[] { + return keys.map((key) => { + if (!(key in CONST.PLATFORM_SPECIFIC_KEYS)) { + return key; + } + + const platformModifiers = CONST.PLATFORM_SPECIFIC_KEYS[key as keyof typeof CONST.PLATFORM_SPECIFIC_KEYS]; + return platformModifiers?.[operatingSystem as keyof typeof platformModifiers] ?? platformModifiers.DEFAULT ?? key; + }); +} + +/** + * Subscribes to a keyboard event. + * @param key The key to watch, i.e. 'K' or 'Escape' + * @param callback The callback to call + * @param descriptionKey Translation key for shortcut description + * @param [modifiers] Can either be shift or control + * @param [captureOnInputs] Should we capture the event on inputs too? + * @param [shouldBubble] Should the event bubble? + * @param [priority] The position the callback should take in the stack. 0 means top priority, and 1 means less priority than the most recently added. + * @param [shouldPreventDefault] Should call event.preventDefault after callback? + * @param [excludedNodes] Do not capture key events targeting excluded nodes (i.e. do not prevent default and let the event bubble) + * @returns clean up method + */ +function subscribe( + key: string, + callback: () => void, + descriptionKey: string, + modifiers: string[] = ['shift'], + captureOnInputs = false, + shouldBubble = false, + priority = 0, + shouldPreventDefault = true, + excludedNodes = [], +) { + const platformAdjustedModifiers = getPlatformEquivalentForKeys(modifiers); + const displayName = getDisplayName(key, platformAdjustedModifiers); + if (!eventHandlers[displayName]) { + eventHandlers[displayName] = []; + } + + const callbackID = Str.guid(); + eventHandlers[displayName].splice(priority, 0, { + id: callbackID, + callback, + captureOnInputs, + shouldPreventDefault, + shouldBubble, + excludedNodes, + }); + + if (descriptionKey) { + documentedShortcuts[displayName] = { + shortcutKey: key, + descriptionKey, + displayName, + modifiers, + }; + } + + return () => unsubscribe(displayName, callbackID); +} + +/** + * 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. + * + * - 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. + * + * - 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, + getDisplayName, + getDocumentedShortcuts, + getPlatformEquivalentForKeys, +}; + +export default KeyboardShortcut; +export type {EventHandler}; diff --git a/src/libs/KeyboardShortcut/isEnterWhileComposition.js b/src/libs/KeyboardShortcut/isEnterWhileComposition.ts similarity index 80% rename from src/libs/KeyboardShortcut/isEnterWhileComposition.js rename to src/libs/KeyboardShortcut/isEnterWhileComposition.ts index 6269440716b57..2a0a2fec110f8 100644 --- a/src/libs/KeyboardShortcut/isEnterWhileComposition.js +++ b/src/libs/KeyboardShortcut/isEnterWhileComposition.ts @@ -1,13 +1,12 @@ +import {NativeSyntheticEvent} from 'react-native'; import * as Browser from '../Browser'; import CONST from '../../CONST'; /** * Check if the Enter key was pressed during IME confirmation (i.e. while the text is being composed). * See {@link https://en.wikipedia.org/wiki/Input_method} - * @param {Event} event - * @returns {boolean} */ -const isEnterWhileComposition = (event) => { +const isEnterWhileComposition = (event: KeyboardEvent): boolean => { // if on mobile chrome, the enter key event is never fired when the enter key is pressed while composition. if (Browser.isMobileChrome()) { return false; @@ -18,7 +17,8 @@ const isEnterWhileComposition = (event) => { if (CONST.BROWSER.SAFARI === Browser.getBrowser()) { return event.keyCode === 229; } - return event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && event.nativeEvent && event.nativeEvent.isComposing; + + return event.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && (event as unknown as NativeSyntheticEvent)?.nativeEvent?.isComposing; }; export default isEnterWhileComposition; diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index faa710d2cd6b4..7284b965de501 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -447,19 +447,19 @@ function ComposerWithSuggestions({ }, []); useEffect(() => { - const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress)); + const unsubscribeNavigationBlur = navigation.addListener('blur', () => KeyDownListener.removeKeyDownPressListener(focusComposerOnKeyPress)); const unsubscribeNavigationFocus = navigation.addListener('focus', () => { - KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + KeyDownListener.addKeyDownPressListener(focusComposerOnKeyPress); setUpComposeFocusManager(); }); - KeyDownListener.addKeyDownPressListner(focusComposerOnKeyPress); + KeyDownListener.addKeyDownPressListener(focusComposerOnKeyPress); setUpComposeFocusManager(); return () => { ReportActionComposeFocusManager.clear(true); - KeyDownListener.removeKeyDownPressListner(focusComposerOnKeyPress); + KeyDownListener.removeKeyDownPressListener(focusComposerOnKeyPress); unsubscribeNavigationBlur(); unsubscribeNavigationFocus(); }; diff --git a/src/types/modules/react-native-key-command.d.ts b/src/types/modules/react-native-key-command.d.ts index f93204891e845..6af989e33814e 100644 --- a/src/types/modules/react-native-key-command.d.ts +++ b/src/types/modules/react-native-key-command.d.ts @@ -21,9 +21,9 @@ declare module 'react-native-key-command' { keyModifierAlternate: 'keyModifierAlternate', } as const; - type KeyCommand = {input: string; modifierFlags: string}; + type KeyCommandEvent = {input: string; modifierFlags?: string}; - declare function addListener(keyCommand: KeyCommand, callback: (keycommandEvent: KeyCommand, event: Event) => void): () => void; + declare function addListener(keyCommand: KeyCommandEvent, callback: (keyCommandEvent: KeyCommand, event: KeyboardEvent) => void): () => void; // eslint-disable-next-line import/prefer-default-export export {constants, addListener};