addons/components/KeyboardShortcuts.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {FunctionComponent, PropsWithChildren} from 'react';
b69ab319
b69ab3110import {createContext, useContext, useEffect} from 'react';
b69ab3111
b69ab3112/* eslint-disable no-bitwise */
b69ab3113
b69ab3114type Modifiers = Modifier | Array<Modifier>;
b69ab3115/**
b69ab3116 * Modifiers for keyboard shortcuts, intended to be bitwise-OR'd together.
b69ab3117 * e.g. `Modifier.CMD | Modifier.CTRL`.
b69ab3118 */
b69ab3119export enum Modifier {
b69ab3120 NONE = 0,
b69ab3121 SHIFT = 1 << 0,
b69ab3122 CTRL = 1 << 1,
b69ab3123 ALT = 1 << 2,
b69ab3124 CMD = 1 << 3,
b69ab3125}
b69ab3126
b69ab3127export enum KeyCode {
b69ab3128 Escape = 27,
b69ab3129 One = 49,
b69ab3130 Two = 50,
b69ab3131 Three = 51,
b69ab3132 Four = 52,
b69ab3133 Five = 53,
b69ab3134 A = 65,
b69ab3135 B = 66,
b69ab3136 C = 67,
b69ab3137 D = 68,
b69ab3138 F = 70,
b69ab3139 G = 71,
b69ab3140 M = 77,
b69ab3141 N = 78,
b69ab3142 P = 80,
b69ab3143 R = 82,
b69ab3144 S = 83,
b69ab3145 T = 84,
b69ab3146 Period = 190,
b69ab3147 QuestionMark = 191,
b69ab3148 SingleQuote = 222,
b69ab3149 LeftArrow = 37,
b69ab3150 UpArrow = 38,
b69ab3151 RightArrow = 39,
b69ab3152 DownArrow = 40,
b69ab3153 Backspace = 8,
b69ab3154 Plus = 187,
b69ab3155 Minus = 189,
b69ab3156}
b69ab3157
b69ab3158type CommandDefinition = [Modifiers, KeyCode];
b69ab3159
b69ab3160type CommandMap<CommandName extends string> = Record<CommandName, CommandDefinition>;
b69ab3161
b69ab3162function isTargetTextInputElement(event: KeyboardEvent): boolean {
b69ab3163 return (
b69ab3164 event.target != null &&
b69ab3165 /(vscode-text-area|vscode-text-field|textarea|input)/i.test(
b69ab3166 (event.target as HTMLElement).tagName,
b69ab3167 )
b69ab3168 );
b69ab3169}
b69ab3170
b69ab3171class CommandDispatcher<CommandName extends string> extends (
b69ab3172 window as {
b69ab3173 EventTarget: {
b69ab3174 new (): EventTarget;
b69ab3175 prototype: EventTarget;
b69ab3176 };
b69ab3177 }
b69ab3178).EventTarget {
b69ab3179 private keydownListener: (event: KeyboardEvent) => void;
b69ab3180 constructor(commands: CommandMap<CommandName>) {
b69ab3181 super();
b69ab3182 const knownKeysWithCommands = new Set<KeyCode>();
b69ab3183 for (const cmdDef of Object.values(commands) as Array<CommandDefinition>) {
b69ab3184 const [, key] = cmdDef;
b69ab3185 knownKeysWithCommands.add(key);
b69ab3186 }
b69ab3187 this.keydownListener = (event: KeyboardEvent) => {
b69ab3188 if (!knownKeysWithCommands.has(event.keyCode)) {
b69ab3189 return;
b69ab3190 }
b69ab3191 if (isTargetTextInputElement(event)) {
b69ab3192 // we don't want shortcuts to interfere with text entry
b69ab3193 return;
b69ab3194 }
b69ab3195 const modValue =
b69ab3196 (event.shiftKey ? Modifier.SHIFT : 0) |
b69ab3197 (event.ctrlKey ? Modifier.CTRL : 0) |
b69ab3198 (event.altKey ? Modifier.ALT : 0) |
b69ab3199 (event.metaKey ? Modifier.CMD : 0);
b69ab31100
b69ab31101 for (const [command, cmdAttrs] of Object.entries(commands) as Array<
b69ab31102 [CommandName, CommandDefinition]
b69ab31103 >) {
b69ab31104 const [mods, key] = cmdAttrs;
b69ab31105 if (key === event.keyCode && collapseModifiersToNumber(mods) === modValue) {
b69ab31106 this.dispatchEvent(new Event(command));
b69ab31107 break;
b69ab31108 }
b69ab31109 }
b69ab31110 };
b69ab31111 document.body.addEventListener('keydown', this.keydownListener);
b69ab31112 }
b69ab31113}
b69ab31114
b69ab31115function collapseModifiersToNumber(mods: Modifiers): number {
b69ab31116 return Array.isArray(mods) ? mods.reduce((acc, mod) => acc | mod, Modifier.NONE) : mods;
b69ab31117}
b69ab31118
b69ab31119/**
b69ab31120 * Add support for commands which are triggered by keyboard shortcuts.
b69ab31121 * return a top-level context provider which listens for global keyboard input,
b69ab31122 * plus a `useCommand` hook that lets you handle commands as they are dispatched,
b69ab31123 * plus a callback to dispatch events at any point in code (to simulate keyboard shortcuts).
b69ab31124 *
b69ab31125 * Commands are defined by mapping string command names to a key plus a set of modifiers.
b69ab31126 * CommandNames are statically known so that `useCommand` is type-safe.
b69ab31127 * Modifiers are a bitwise-OR union of {@link Modifier}, like `Modifier.CTRL | Modifier.CMD`
b69ab31128 *
b69ab31129 * Commands are not dispatched when the target is an input element, to ensure we don't affect typing.
b69ab31130 */
b69ab31131export function makeCommandDispatcher<CommandName extends string>(
b69ab31132 commands: CommandMap<CommandName>,
b69ab31133): [
b69ab31134 FunctionComponent<PropsWithChildren>,
b69ab31135 (command: CommandName, handler: () => void) => void,
b69ab31136 (command: CommandName) => void,
b69ab31137 CommandMap<CommandName>,
b69ab31138] {
b69ab31139 const commandDispatcher = new CommandDispatcher(commands);
b69ab31140 const Context = createContext(commandDispatcher);
b69ab31141
b69ab31142 function useCommand(command: CommandName, handler: () => void) {
b69ab31143 const dispatcher = useContext(Context);
b69ab31144
b69ab31145 // register & unregister the event listener while the component is mounted
b69ab31146 useEffect(() => {
b69ab31147 dispatcher.addEventListener(command, handler);
b69ab31148 return () => dispatcher.removeEventListener(command, handler);
b69ab31149 }, [command, handler, dispatcher]);
b69ab31150 }
b69ab31151
b69ab31152 return [
b69ab31153 ({children}) => <Context.Provider value={commandDispatcher}>{children}</Context.Provider>,
b69ab31154 useCommand,
b69ab31155 (command: CommandName) => commandDispatcher.dispatchEvent(new Event(command)),
b69ab31156 commands,
b69ab31157 ];
b69ab31158}