• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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