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 | ']' 92 | ',' 93 | '.'; 94export type Key = Alphabet | Number | Special; 95export type Modifier = 96 | '' 97 | 'Mod+' 98 | 'Shift+' 99 | 'Ctrl+' 100 | 'Alt+' 101 | 'Mod+Shift+' 102 | 'Mod+Alt+' 103 | 'Mod+Shift+Alt+' 104 | 'Ctrl+Shift+' 105 | 'Ctrl+Alt' 106 | 'Ctrl+Shift+Alt'; 107type AllowInEditable = '!' | ''; 108export type Hotkey = `${AllowInEditable}${Modifier}${Key}`; 109 110// The following list of keys cannot be pressed wither with or without the 111// presence of the Shift modifier on most keyboard layouts. Thus we should 112// ignore shift in these cases. 113const shiftExceptions = [ 114 '0', 115 '1', 116 '2', 117 '3', 118 '4', 119 '5', 120 '6', 121 '7', 122 '8', 123 '9', 124 '/', 125 '?', 126 '!', 127 '[', 128 ']', 129]; 130 131const macModifierStrings: ReadonlyMap<Modifier, string> = new Map< 132 Modifier, 133 string 134>([ 135 ['', ''], 136 ['Mod+', '⌘'], 137 ['Shift+', '⇧'], 138 ['Ctrl+', '⌃'], 139 ['Alt+', '⌥'], 140 ['Mod+Shift+', '⌘⇧'], 141 ['Mod+Alt+', '⌘⌥'], 142 ['Mod+Shift+Alt+', '⌘⇧⌥'], 143 ['Ctrl+Shift+', '⌃⇧'], 144 ['Ctrl+Alt', '⌃⌥'], 145 ['Ctrl+Shift+Alt', '⌃⇧⌥'], 146]); 147 148const pcModifierStrings: ReadonlyMap<Modifier, string> = new Map< 149 Modifier, 150 string 151>([ 152 ['', ''], 153 ['Mod+', 'Ctrl+'], 154 ['Mod+Shift+', 'Ctrl+Shift+'], 155 ['Mod+Alt+', 'Ctrl+Alt+'], 156 ['Mod+Shift+Alt+', 'Ctrl+Shift+Alt+'], 157]); 158 159// Represents a deconstructed hotkey. 160export interface HotkeyParts { 161 // The name of the primary key of this hotkey. 162 key: Key; 163 164 // All the modifiers as one chunk. E.g. 'Mod+Shift+'. 165 modifier: Modifier; 166 167 // Whether this hotkey should register when the event target is inside an 168 // editable field. 169 allowInEditable: boolean; 170} 171 172// Deconstruct a hotkey from its string representation into its constituent 173// parts. 174export function parseHotkey(hotkey: Hotkey): HotkeyParts | undefined { 175 const regex = /^(!?)((?:Mod\+|Shift\+|Alt\+|Ctrl\+)*)(.*)$/; 176 const result = hotkey.match(regex); 177 178 if (!result) { 179 return undefined; 180 } 181 182 return { 183 allowInEditable: result[1] === '!', 184 modifier: result[2] as Modifier, 185 key: result[3] as Key, 186 }; 187} 188 189// Print the hotkey in a human readable format. 190export function formatHotkey( 191 hotkey: Hotkey, 192 spoof?: Platform, 193): string | undefined { 194 const parsed = parseHotkey(hotkey); 195 return parsed && formatHotkeyParts(parsed, spoof); 196} 197 198function formatHotkeyParts( 199 {modifier, key}: HotkeyParts, 200 spoof?: Platform, 201): string { 202 return `${formatModifier(modifier, spoof)}${key}`; 203} 204 205function formatModifier(modifier: Modifier, spoof?: Platform): string { 206 const platform = spoof || getPlatform(); 207 const strings = platform === 'Mac' ? macModifierStrings : pcModifierStrings; 208 return strings.get(modifier) ?? modifier; 209} 210 211// Like |KeyboardEvent| but all fields apart from |key| are optional. 212export type KeyboardEventLike = Pick<KeyboardEvent, 'key'> & 213 Partial<KeyboardEvent>; 214 215// Check whether |hotkey| is present in the keyboard event |event|. 216export function checkHotkey( 217 hotkey: Hotkey, 218 event: KeyboardEventLike, 219 spoofPlatform?: Platform, 220): boolean { 221 const result = parseHotkey(hotkey); 222 if (!result) { 223 return false; 224 } 225 226 const {key, allowInEditable} = result; 227 const {target = null} = event; 228 229 const inEditable = elementIsEditable(target); 230 if (inEditable && !allowInEditable) { 231 return false; 232 } 233 return compareKeys(event, key) && checkMods(event, result, spoofPlatform); 234} 235 236// Return true if |key| matches the event's key. 237function compareKeys(e: KeyboardEventLike, key: Key): boolean { 238 return e.key.toLowerCase() === key.toLowerCase(); 239} 240 241// Return true if modifiers specified in |mods| match those in the event. 242function checkMods( 243 event: KeyboardEventLike, 244 hotkey: HotkeyParts, 245 spoofPlatform?: Platform, 246): boolean { 247 const platform = spoofPlatform ?? getPlatform(); 248 249 const {key, modifier} = hotkey; 250 251 const { 252 ctrlKey = false, 253 altKey = false, 254 shiftKey = false, 255 metaKey = false, 256 } = event; 257 258 const wantShift = modifier.includes('Shift'); 259 const wantAlt = modifier.includes('Alt'); 260 const wantCtrl = 261 platform === 'Mac' 262 ? modifier.includes('Ctrl') 263 : modifier.includes('Ctrl') || modifier.includes('Mod'); 264 const wantMeta = platform === 'Mac' && modifier.includes('Mod'); 265 266 // For certain keys we relax the shift requirement, as they usually cannot be 267 // pressed without the shift key on English keyboards. 268 const shiftOk = 269 shiftExceptions.includes(key as string) || shiftKey === wantShift; 270 271 return ( 272 metaKey === wantMeta && 273 Boolean(shiftOk) && 274 altKey === wantAlt && 275 ctrlKey === wantCtrl 276 ); 277} 278 279export type Platform = 'Mac' | 'PC'; 280 281// Get the current platform (PC or Mac). 282export function getPlatform(): Platform { 283 return window.navigator.platform.indexOf('Mac') !== -1 ? 'Mac' : 'PC'; 284} 285 286// Returns a cross-platform check for whether the event has "Mod" key pressed 287// (e.g. as a part of Mod-Click UX pattern). 288// On Mac, Mod-click is actually Command-click and on PC it's Control-click, 289// so this function handles this for all platforms. 290export function hasModKey(event: { 291 readonly metaKey: boolean; 292 readonly ctrlKey: boolean; 293}): boolean { 294 if (getPlatform() === 'Mac') { 295 return event.metaKey; 296 } else { 297 return event.ctrlKey; 298 } 299} 300 301export function modKey(): {metaKey?: boolean; ctrlKey?: boolean} { 302 if (getPlatform() === 'Mac') { 303 return {metaKey: true}; 304 } else { 305 return {ctrlKey: true}; 306 } 307} 308