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