Skip to content

Commit

Permalink
feat: shortcuts rework (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
timc1 authored Mar 18, 2022
1 parent 8f75115 commit a8dad02
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 58 deletions.
17 changes: 3 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@
"fast-equals": "^2.0.3",
"match-sorter": "^6.3.0",
"react-virtual": "^2.8.2",
"tiny-invariant": "^1.2.0",
"tinykeys": "^1.4.0"
"tiny-invariant": "^1.2.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0",
Expand Down
72 changes: 30 additions & 42 deletions src/InternalEvents.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from "react";
import tinykeys from "./tinykeys";
import { VisualState } from "./types";
import { useKBar } from "./useKBar";
import { getScrollbarWidth, shouldRejectKeystrokes } from "./utils";
import tinykeys from "tinykeys";

type Timeout = ReturnType<typeof setTimeout>;

Expand All @@ -24,19 +24,21 @@ function useToggleHandler() {
}));

React.useEffect(() => {
const trigger = options.toggleShortcut || "$mod+k";
const shortcut = options.toggleShortcut || "$mod+k";

const unsubscribe = tinykeys(window, {
[trigger]: (event) => {
[shortcut]: (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
event.preventDefault();
query.toggle();

if (showing) {
options.callbacks?.onClose?.();
} else {
options.callbacks?.onOpen?.();
}
},
Escape: (event) => {
Escape: (event: KeyboardEvent) => {
if (showing) {
event.stopPropagation();
options.callbacks?.onClose?.();
Expand Down Expand Up @@ -134,14 +136,13 @@ function useDocumentLock() {
}
}
}, [
options.disableScrollbarManagement,
options.disableDocumentLock,
options.disableScrollbarManagement,
visualState,
]);
}

/**
* TODO: We can simplify this implementation to use `tinykeys`
* `useShortcuts` registers and listens to keyboard strokes and
* performs actions for patterns that match the user defined `shortcut`.
*/
Expand All @@ -153,49 +154,36 @@ function useShortcuts() {
React.useEffect(() => {
const actionsList = Object.keys(actions).map((key) => actions[key]);

let buffer: string[] = [];
let lastKeyStrokeTime = Date.now();

function handleKeyDown(event: KeyboardEvent) {
const key = event.key?.toLowerCase();

if (shouldRejectKeystrokes() || event.metaKey || key === "shift") {
return;
const shortcutsMap = {};
for (let action of actionsList) {
if (!action.shortcut?.length) {
continue;
}

const currentTime = Date.now();

if (currentTime - lastKeyStrokeTime > 400) {
buffer = [];
}
const shortcut = action.shortcut.join(" ");

buffer.push(key);
lastKeyStrokeTime = currentTime;
const bufferString = buffer.join("");

for (let action of actionsList) {
if (!action.shortcut) {
continue;
}
if (action.shortcut.join("") === bufferString) {
event.preventDefault();
if (action.children?.length) {
query.setCurrentRootAction(action.id);
query.toggle();
options.callbacks?.onOpen?.();
} else {
action.command?.perform();
options.callbacks?.onSelectAction?.(action);
}
shortcutsMap[shortcut] = (event: KeyboardEvent) => {
if (shouldRejectKeystrokes()) return;

buffer = [];
break;
event.preventDefault();
if (action.children?.length) {
query.setCurrentRootAction(action.id);
query.toggle();
options.callbacks?.onOpen?.();
} else {
action.command?.perform();
options.callbacks?.onSelectAction?.(action);
}
}
};
}

window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
const unsubscribe = tinykeys(window, shortcutsMap, {
timeout: 400,
});

return () => {
unsubscribe();
};
}, [actions, options.callbacks, query]);
}

Expand Down
208 changes: 208 additions & 0 deletions src/tinykeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
// Fixes special character issues; `?` -> `shift+/` + build issue
// https://github.com/jamiebuilds/tinykeys

type KeyBindingPress = [string[], string];

/**
* A map of keybinding strings to event handlers.
*/
export interface KeyBindingMap {
[keybinding: string]: (event: KeyboardEvent) => void;
}

/**
* Options to configure the behavior of keybindings.
*/
export interface KeyBindingOptions {
/**
* Key presses will listen to this event (default: "keydown").
*/
event?: "keydown" | "keyup";

/**
* Keybinding sequences will wait this long between key presses before
* cancelling (default: 1000).
*
* **Note:** Setting this value too low (i.e. `300`) will be too fast for many
* of your users.
*/
timeout?: number;
}

/**
* These are the modifier keys that change the meaning of keybindings.
*
* Note: Ignoring "AltGraph" because it is covered by the others.
*/
let KEYBINDING_MODIFIER_KEYS = ["Shift", "Meta", "Alt", "Control"];

/**
* Keybinding sequences should timeout if individual key presses are more than
* 1s apart by default.
*/
let DEFAULT_TIMEOUT = 1000;

/**
* Keybinding sequences should bind to this event by default.
*/
let DEFAULT_EVENT = "keydown";

/**
* An alias for creating platform-specific keybinding aliases.
*/
let MOD =
typeof navigator === "object" &&
/Mac|iPod|iPhone|iPad/.test(navigator.platform)
? "Meta"
: "Control";

/**
* There's a bug in Chrome that causes event.getModifierState not to exist on
* KeyboardEvent's for F1/F2/etc keys.
*/
function getModifierState(event: KeyboardEvent, mod: string) {
return typeof event.getModifierState === "function"
? event.getModifierState(mod)
: false;
}

/**
* Parses a "Key Binding String" into its parts
*
* grammar = `<sequence>`
* <sequence> = `<press> <press> <press> ...`
* <press> = `<key>` or `<mods>+<key>`
* <mods> = `<mod>+<mod>+...`
*/
function parse(str: string): KeyBindingPress[] {
return str
.trim()
.split(" ")
.map((press) => {
let mods = press.split(/\b\+/);
let key = mods.pop() as string;
mods = mods.map((mod) => (mod === "$mod" ? MOD : mod));
return [mods, key];
});
}

/**
* This tells us if a series of events matches a key binding sequence either
* partially or exactly.
*/
function match(event: KeyboardEvent, press: KeyBindingPress): boolean {
// Special characters; `?` `!`
if (/^[^A-Za-z0-9]$/.test(event.key) && press[1] === event.key) {
return true;
}

// prettier-ignore
return !(
// Allow either the `event.key` or the `event.code`
// MDN event.key: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
// MDN event.code: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code
(
press[1].toUpperCase() !== event.key.toUpperCase() &&
press[1] !== event.code
) ||

// Ensure all the modifiers in the keybinding are pressed.
press[0].find(mod => {
return !getModifierState(event, mod)
}) ||

// KEYBINDING_MODIFIER_KEYS (Shift/Control/etc) change the meaning of a
// keybinding. So if they are pressed but aren't part of the current
// keybinding press, then we don't have a match.
KEYBINDING_MODIFIER_KEYS.find(mod => {
return !press[0].includes(mod) && press[1] !== mod && getModifierState(event, mod)
})
)
}

/**
* Subscribes to keybindings.
*
* Returns an unsubscribe method.
*
* @example
* ```js
* import keybindings from "../src/keybindings"
*
* keybindings(window, {
* "Shift+d": () => {
* alert("The 'Shift' and 'd' keys were pressed at the same time")
* },
* "y e e t": () => {
* alert("The keys 'y', 'e', 'e', and 't' were pressed in order")
* },
* "$mod+d": () => {
* alert("Either 'Control+d' or 'Meta+d' were pressed")
* },
* })
* ```
*/
export default function keybindings(
target: Window | HTMLElement,
keyBindingMap: KeyBindingMap,
options: KeyBindingOptions = {}
): () => void {
let timeout = options.timeout ?? DEFAULT_TIMEOUT;
let event = options.event ?? DEFAULT_EVENT;

let keyBindings = Object.keys(keyBindingMap).map((key) => {
return [parse(key), keyBindingMap[key]] as const;
});

let possibleMatches = new Map<KeyBindingPress[], KeyBindingPress[]>();
let timer: number | null = null;

let onKeyEvent: EventListener = (event) => {
// Ensure and stop any event that isn't a full keyboard event.
// Autocomplete option navigation and selection would fire a instanceof Event,
// instead of the expected KeyboardEvent
if (!(event instanceof KeyboardEvent)) {
return;
}

keyBindings.forEach((keyBinding) => {
let sequence = keyBinding[0];
let callback = keyBinding[1];

let prev = possibleMatches.get(sequence);
let remainingExpectedPresses = prev ? prev : sequence;
let currentExpectedPress = remainingExpectedPresses[0];

let matches = match(event, currentExpectedPress);

if (!matches) {
// Modifier keydown events shouldn't break sequences
// Note: This works because:
// - non-modifiers will always return false
// - if the current keypress is a modifier then it will return true when we check its state
// MDN: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState
if (!getModifierState(event, event.key)) {
possibleMatches.delete(sequence);
}
} else if (remainingExpectedPresses.length > 1) {
possibleMatches.set(sequence, remainingExpectedPresses.slice(1));
} else {
possibleMatches.delete(sequence);
callback(event);
}
});

if (timer) {
clearTimeout(timer);
}

// @ts-ignore
timer = setTimeout(possibleMatches.clear.bind(possibleMatches), timeout);
};

target.addEventListener(event, onKeyEvent);

return () => {
target.removeEventListener(event, onKeyEvent);
};
}

1 comment on commit a8dad02

@vercel
Copy link

@vercel vercel bot commented on a8dad02 Mar 18, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

Please sign in to comment.