xref: /aosp_15_r20/external/perfetto/ui/src/base/hotkeys.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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