1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15// This module provides hotkey detection using type-safe human-readable strings. 16// 17// The basic premise is this: Let's say you have a KeyboardEvent |event|, and 18// you wanted to check whether it contains the hotkey 'Ctrl+O', you can execute 19// the following function: 20// 21// checkHotkey('Shift+O', event); 22// 23// ...which will evaluate to true if 'Shift+O' is discovered in the event. 24// 25// This will only trigger when O is pressed while the Shift key is held, not O 26// on it's own, and not if other modifiers such as Alt or Ctrl were also held. 27// 28// Modifiers include 'Shift', 'Ctrl', 'Alt', and 'Mod': 29// - 'Shift' and 'Ctrl' are fairly self explanatory. 30// - 'Alt' is 'option' on Macs. 31// - 'Mod' is a special modifier which means 'Ctrl' on PC and 'Cmd' on Mac. 32// Modifiers may be combined in various ways - check the |Modifier| type. 33// 34// By default hotkeys will not register when the event target is inside an 35// editable element, such as <textarea> and some <input>s. 36// Prefixing a hotkey with a bang '!' relaxes is requirement, meaning the hotkey 37// will register inside editable fields. 38 39// E.g. '!Mod+Shift+P' will register when pressed when a text box has focus but 40// 'Mod+Shift+P' (no bang) will not. 41// Warning: Be careful using this with single key hotkeys, e.g. '!P' is usually 42// never what you want! 43// 44// Some single-key hotkeys like '?' and '!' normally cannot be activated in 45// without also pressing shift key, so the shift requirement is relaxed for 46// these keys. 47 48import {elementIsEditable} from './dom_utils'; 49 50type Alphabet = 51 | 'A' 52 | 'B' 53 | 'C' 54 | 'D' 55 | 'E' 56 | 'F' 57 | 'G' 58 | 'H' 59 | 'I' 60 | 'J' 61 | 'K' 62 | 'L' 63 | 'M' 64 | 'N' 65 | 'O' 66 | 'P' 67 | 'Q' 68 | 'R' 69 | 'S' 70 | 'T' 71 | 'U' 72 | 'V' 73 | 'W' 74 | 'X' 75 | 'Y' 76 | 'Z'; 77type Number = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; 78type Special = 79 | 'Enter' 80 | 'Escape' 81 | 'Delete' 82 | '/' 83 | '?' 84 | '!' 85 | 'Space' 86 | 'ArrowUp' 87 | 'ArrowDown' 88 | 'ArrowLeft' 89 | 'ArrowRight' 90 | '[' 91 | ']'; 92export type Key = Alphabet | Number | Special; 93export type Modifier = 94 | '' 95 | 'Mod+' 96 | 'Shift+' 97 | 'Ctrl+' 98 | 'Alt+' 99 | 'Mod+Shift+' 100 | 'Mod+Alt+' 101 | 'Mod+Shift+Alt+' 102 | 'Ctrl+Shift+' 103 | 'Ctrl+Alt' 104 | 'Ctrl+Shift+Alt'; 105type AllowInEditable = '!' | ''; 106export type Hotkey = `${AllowInEditable}${Modifier}${Key}`; 107 108// The following list of keys cannot be pressed wither with or without the 109// presence of the Shift modifier on most keyboard layouts. Thus we should 110// ignore shift in these cases. 111const shiftExceptions = [ 112 '0', 113 '1', 114 '2', 115 '3', 116 '4', 117 '5', 118 '6', 119 '7', 120 '8', 121 '9', 122 '/', 123 '?', 124 '!', 125 '[', 126 ']', 127]; 128 129// Represents a deconstructed hotkey. 130export interface HotkeyParts { 131 // The name of the primary key of this hotkey. 132 key: Key; 133 134 // All the modifiers as one chunk. E.g. 'Mod+Shift+'. 135 modifier: Modifier; 136 137 // Whether this hotkey should register when the event target is inside an 138 // editable field. 139 allowInEditable: boolean; 140} 141 142// Deconstruct a hotkey from its string representation into its constituent 143// parts. 144export function parseHotkey(hotkey: Hotkey): HotkeyParts | null { 145 const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/; 146 const result = hotkey.match(regex); 147 148 if (!result) { 149 return null; 150 } 151 152 return { 153 allowInEditable: result[1] === '!', 154 modifier: result[2] as Modifier, 155 key: result[3] as Key, 156 }; 157} 158 159// Like |KeyboardEvent| but all fields apart from |key| are optional. 160export type KeyboardEventLike = Pick<KeyboardEvent, 'key'> & 161 Partial<KeyboardEvent>; 162 163// Check whether |hotkey| is present in the keyboard event |event|. 164export function checkHotkey( 165 hotkey: Hotkey, 166 event: KeyboardEventLike, 167 spoofPlatform?: Platform, 168): boolean { 169 const result = parseHotkey(hotkey); 170 if (!result) { 171 return false; 172 } 173 174 const {key, allowInEditable} = result; 175 const {target = null} = event; 176 177 const inEditable = elementIsEditable(target); 178 if (inEditable && !allowInEditable) { 179 return false; 180 } 181 return compareKeys(event, key) && checkMods(event, result, spoofPlatform); 182} 183 184// Return true if |key| matches the event's key. 185function compareKeys(e: KeyboardEventLike, key: Key): boolean { 186 return e.key.toLowerCase() === key.toLowerCase(); 187} 188 189// Return true if modifiers specified in |mods| match those in the event. 190function checkMods( 191 event: KeyboardEventLike, 192 hotkey: HotkeyParts, 193 spoofPlatform?: Platform, 194): boolean { 195 const platform = spoofPlatform ?? getPlatform(); 196 197 const {key, modifier} = hotkey; 198 199 const { 200 ctrlKey = false, 201 altKey = false, 202 shiftKey = false, 203 metaKey = false, 204 } = event; 205 206 const wantShift = modifier.includes('Shift'); 207 const wantAlt = modifier.includes('Alt'); 208 const wantCtrl = 209 platform === 'Mac' 210 ? modifier.includes('Ctrl') 211 : modifier.includes('Ctrl') || modifier.includes('Mod'); 212 const wantMeta = platform === 'Mac' && modifier.includes('Mod'); 213 214 // For certain keys we relax the shift requirement, as they usually cannot be 215 // pressed without the shift key on English keyboards. 216 const shiftOk = 217 shiftExceptions.includes(key as string) || shiftKey === wantShift; 218 219 return ( 220 metaKey === wantMeta && 221 Boolean(shiftOk) && 222 altKey === wantAlt && 223 ctrlKey === wantCtrl 224 ); 225} 226 227export type Platform = 'Mac' | 'PC'; 228 229// Get the current platform (PC or Mac). 230export function getPlatform(spoof?: Platform): Platform { 231 if (spoof) { 232 return spoof; 233 } else { 234 return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC'; 235 } 236} 237