4.5 KB159 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {FunctionComponent, PropsWithChildren} from 'react';
9
10import {createContext, useContext, useEffect} from 'react';
11
12/* eslint-disable no-bitwise */
13
14type Modifiers = Modifier | Array<Modifier>;
15/**
16 * Modifiers for keyboard shortcuts, intended to be bitwise-OR'd together.
17 * e.g. `Modifier.CMD | Modifier.CTRL`.
18 */
19export enum Modifier {
20 NONE = 0,
21 SHIFT = 1 << 0,
22 CTRL = 1 << 1,
23 ALT = 1 << 2,
24 CMD = 1 << 3,
25}
26
27export enum KeyCode {
28 Escape = 27,
29 One = 49,
30 Two = 50,
31 Three = 51,
32 Four = 52,
33 Five = 53,
34 A = 65,
35 B = 66,
36 C = 67,
37 D = 68,
38 F = 70,
39 G = 71,
40 M = 77,
41 N = 78,
42 P = 80,
43 R = 82,
44 S = 83,
45 T = 84,
46 Period = 190,
47 QuestionMark = 191,
48 SingleQuote = 222,
49 LeftArrow = 37,
50 UpArrow = 38,
51 RightArrow = 39,
52 DownArrow = 40,
53 Backspace = 8,
54 Plus = 187,
55 Minus = 189,
56}
57
58type CommandDefinition = [Modifiers, KeyCode];
59
60type CommandMap<CommandName extends string> = Record<CommandName, CommandDefinition>;
61
62function isTargetTextInputElement(event: KeyboardEvent): boolean {
63 return (
64 event.target != null &&
65 /(vscode-text-area|vscode-text-field|textarea|input)/i.test(
66 (event.target as HTMLElement).tagName,
67 )
68 );
69}
70
71class CommandDispatcher<CommandName extends string> extends (
72 window as {
73 EventTarget: {
74 new (): EventTarget;
75 prototype: EventTarget;
76 };
77 }
78).EventTarget {
79 private keydownListener: (event: KeyboardEvent) => void;
80 constructor(commands: CommandMap<CommandName>) {
81 super();
82 const knownKeysWithCommands = new Set<KeyCode>();
83 for (const cmdDef of Object.values(commands) as Array<CommandDefinition>) {
84 const [, key] = cmdDef;
85 knownKeysWithCommands.add(key);
86 }
87 this.keydownListener = (event: KeyboardEvent) => {
88 if (!knownKeysWithCommands.has(event.keyCode)) {
89 return;
90 }
91 if (isTargetTextInputElement(event)) {
92 // we don't want shortcuts to interfere with text entry
93 return;
94 }
95 const modValue =
96 (event.shiftKey ? Modifier.SHIFT : 0) |
97 (event.ctrlKey ? Modifier.CTRL : 0) |
98 (event.altKey ? Modifier.ALT : 0) |
99 (event.metaKey ? Modifier.CMD : 0);
100
101 for (const [command, cmdAttrs] of Object.entries(commands) as Array<
102 [CommandName, CommandDefinition]
103 >) {
104 const [mods, key] = cmdAttrs;
105 if (key === event.keyCode && collapseModifiersToNumber(mods) === modValue) {
106 this.dispatchEvent(new Event(command));
107 break;
108 }
109 }
110 };
111 document.body.addEventListener('keydown', this.keydownListener);
112 }
113}
114
115function collapseModifiersToNumber(mods: Modifiers): number {
116 return Array.isArray(mods) ? mods.reduce((acc, mod) => acc | mod, Modifier.NONE) : mods;
117}
118
119/**
120 * Add support for commands which are triggered by keyboard shortcuts.
121 * return a top-level context provider which listens for global keyboard input,
122 * plus a `useCommand` hook that lets you handle commands as they are dispatched,
123 * plus a callback to dispatch events at any point in code (to simulate keyboard shortcuts).
124 *
125 * Commands are defined by mapping string command names to a key plus a set of modifiers.
126 * CommandNames are statically known so that `useCommand` is type-safe.
127 * Modifiers are a bitwise-OR union of {@link Modifier}, like `Modifier.CTRL | Modifier.CMD`
128 *
129 * Commands are not dispatched when the target is an input element, to ensure we don't affect typing.
130 */
131export function makeCommandDispatcher<CommandName extends string>(
132 commands: CommandMap<CommandName>,
133): [
134 FunctionComponent<PropsWithChildren>,
135 (command: CommandName, handler: () => void) => void,
136 (command: CommandName) => void,
137 CommandMap<CommandName>,
138] {
139 const commandDispatcher = new CommandDispatcher(commands);
140 const Context = createContext(commandDispatcher);
141
142 function useCommand(command: CommandName, handler: () => void) {
143 const dispatcher = useContext(Context);
144
145 // register & unregister the event listener while the component is mounted
146 useEffect(() => {
147 dispatcher.addEventListener(command, handler);
148 return () => dispatcher.removeEventListener(command, handler);
149 }, [command, handler, dispatcher]);
150 }
151
152 return [
153 ({children}) => <Context.Provider value={commandDispatcher}>{children}</Context.Provider>,
154 useCommand,
155 (command: CommandName) => commandDispatcher.dispatchEvent(new Event(command)),
156 commands,
157 ];
158}
159