| // Copyright (C) 2023 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // This module provides hotkey detection using type-safe human-readable strings. |
| // |
| // The basic premise is this: Let's say you have a KeyboardEvent |event|, and |
| // you wanted to check whether it contains the hotkey 'Ctrl+O', you can execute |
| // the following function: |
| // |
| // checkHotkey('Shift+O', event); |
| // |
| // ...which will evaluate to true if 'Shift+O' is discovered in the event. |
| // |
| // This will only trigger when O is pressed while the Shift key is held, not O |
| // on it's own, and not if other modifiers such as Alt or Ctrl were also held. |
| // |
| // Modifiers include 'Shift', 'Ctrl', 'Alt', and 'Mod': |
| // - 'Shift' and 'Ctrl' are fairly self explanatory. |
| // - 'Alt' is 'option' on Macs. |
| // - 'Mod' is a special modifier which means 'Ctrl' on PC and 'Cmd' on Mac. |
| // Modifiers may be combined in various ways - check the |Modifier| type. |
| // |
| // By default hotkeys will not register when the event target is inside an |
| // editable element, such as <textarea> and some <input>s. |
| // Prefixing a hotkey with a bang '!' relaxes is requirement, meaning the hotkey |
| // will register inside editable fields. |
| |
| // E.g. '!Mod+Shift+P' will register when pressed when a text box has focus but |
| // 'Mod+Shift+P' (no bang) will not. |
| // Warning: Be careful using this with single key hotkeys, e.g. '!P' is usually |
| // never what you want! |
| // |
| // Some single-key hotkeys like '?' and '!' normally cannot be activated in |
| // without also pressing shift key, so the shift requirement is relaxed for |
| // these keys. |
| |
| import {elementIsEditable} from './dom_utils'; |
| |
| type Alphabet = 'A'|'B'|'C'|'D'|'E'|'F'|'G'|'H'|'I'|'J'|'K'|'L'|'M'|'N'|'O'|'P'| |
| 'Q'|'R'|'S'|'T'|'U'|'V'|'W'|'X'|'Y'|'Z'; |
| type Number = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'; |
| type Special = 'Enter'|'Escape'|'/'|'?'|'!'|'Space'|'ArrowUp'|'ArrowDown'| |
| 'ArrowLeft'|'ArrowRight'; |
| export type Key = Alphabet|Number|Special; |
| export type Modifier = ''|'Mod+'|'Shift+'|'Ctrl+'|'Alt+'|'Mod+Shift+'|'Mod+Alt'| |
| 'Mod+Shift+Alt'|'Ctrl+Shift+'|'Ctrl+Alt'|'Ctrl+Shift+Alt'; |
| type AllowInEditable = '!'|''; |
| export type Hotkey = `${AllowInEditable}${Modifier}${Key}`; |
| |
| // Represents a deconstructed hotkey. |
| export interface HotkeyParts { |
| // The name of the primary key of this hotkey. |
| key: Key; |
| |
| // All the modifiers as one chunk. E.g. 'Mod+Shift+'. |
| modifier: Modifier; |
| |
| // Whether this hotkey should register when the event target is inside an |
| // editable field. |
| allowInEditable: boolean; |
| } |
| |
| // Deconstruct a hotkey from its string representation into its constituent |
| // parts. |
| export function parseHotkey(hotkey: Hotkey): HotkeyParts|null { |
| const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/; |
| const result = hotkey.match(regex); |
| |
| if (!result) { |
| return null; |
| } |
| |
| return { |
| allowInEditable: result[1] === '!', |
| modifier: result[2] as Modifier, |
| key: result[3] as Key, |
| }; |
| } |
| |
| // Like |KeyboardEvent| but all fields apart from |key| are optional. |
| export type KeyboardEventLike = |
| Pick<KeyboardEvent, 'key'>&Partial<KeyboardEvent>; |
| |
| // Check whether |hotkey| is present in the keyboard event |event|. |
| export function checkHotkey( |
| hotkey: Hotkey, event: KeyboardEventLike, spoofPlatform?: Platform): |
| boolean { |
| const result = parseHotkey(hotkey); |
| if (!result) { |
| return false; |
| } |
| |
| const {key, allowInEditable} = result; |
| const {target = null} = event; |
| |
| const inEditable = elementIsEditable(target); |
| if (inEditable && !allowInEditable) { |
| return false; |
| } |
| return compareKeys(event, key) && checkMods(event, result, spoofPlatform); |
| } |
| |
| // Return true if |key| matches the event's key. |
| function compareKeys(e: KeyboardEventLike, key: Key): boolean { |
| return e.key.toLowerCase() === key.toLowerCase(); |
| } |
| |
| // Return true if modifiers specified in |mods| match those in the event. |
| function checkMods( |
| event: KeyboardEventLike, hotkey: HotkeyParts, spoofPlatform?: Platform): |
| boolean { |
| const platform = spoofPlatform ?? getPlatform(); |
| |
| const {key, modifier} = hotkey; |
| |
| const { |
| ctrlKey = false, |
| altKey = false, |
| shiftKey = false, |
| metaKey = false, |
| } = event; |
| |
| const wantShift = modifier.includes('Shift'); |
| const wantAlt = modifier.includes('Alt'); |
| const wantCtrl = platform === 'Mac' ? |
| modifier.includes('Ctrl') : |
| (modifier.includes('Ctrl') || modifier.includes('Mod')); |
| const wantMeta = platform === 'Mac' && modifier.includes('Mod'); |
| |
| // For certain keys we relax the shift requirement, as they usually cannot be |
| // pressed without the shift key on English keyboards. |
| const shiftOk = key.match(/[\?\!]/) || shiftKey === wantShift; |
| |
| return metaKey === wantMeta && Boolean(shiftOk) && altKey === wantAlt && |
| ctrlKey === wantCtrl; |
| } |
| |
| export type Platform = 'Mac'|'PC'; |
| |
| // Get the current platform (PC or Mac). |
| export function getPlatform(spoof?: Platform): Platform { |
| if (spoof) { |
| return spoof; |
| } else { |
| return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC'; |
| } |
| } |