Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions src/lib/constants/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
export const VIM_MOVEMENT_KEYS = {
UP: "k",
DOWN: "j",
LEFT: "h",
RIGHT: "l",
} as const;

export const SHORTCUT_KEYS = {
VIEW_1: "1",
VIEW_2: "2",
Expand All @@ -20,23 +27,52 @@ export const SHORTCUT_KEYS = {
OPEN_ORIGINAL: "o",
SEND_TO_INSTAPAPER: "Shift+S",
UNDO: "z",
ARROW_UP: { key: "ArrowUp", allowRepeat: true },
ARROW_DOWN: { key: "ArrowDown", allowRepeat: true },
ARROW_LEFT: { key: "ArrowLeft", allowRepeat: true },
ARROW_RIGHT: { key: "ArrowRight", allowRepeat: true },
ARROW_UP: {
key: "ArrowUp",
allowRepeat: true,
aliases: [VIM_MOVEMENT_KEYS.UP],
},
ARROW_DOWN: {
key: "ArrowDown",
allowRepeat: true,
aliases: [VIM_MOVEMENT_KEYS.DOWN],
},
ARROW_LEFT: {
key: "ArrowLeft",
allowRepeat: true,
aliases: [VIM_MOVEMENT_KEYS.LEFT],
},
ARROW_RIGHT: {
key: "ArrowRight",
allowRepeat: true,
aliases: [VIM_MOVEMENT_KEYS.RIGHT],
},
ENTER: "Enter",
PREV_VIEW: "[",
NEXT_VIEW: "]",
} as const;

export const MAX_VIEW_SHORTCUTS = 10;

export type ShortcutConfig = string | { key: string; allowRepeat?: boolean };
export type ShortcutConfig =
| string
| { key: string; allowRepeat?: boolean; aliases?: readonly string[] };

export function getShortcutKey(shortcut: ShortcutConfig): string {
return typeof shortcut === "string" ? shortcut : shortcut.key;
}

/**
* Returns every key that should trigger a shortcut, including any aliases
* (e.g. vim-style movement keys that mirror the arrow keys).
*/
export function getShortcutKeys(shortcut: ShortcutConfig): string[] {
if (typeof shortcut === "string") return [shortcut];
return shortcut.aliases
? [shortcut.key, ...shortcut.aliases]
: [shortcut.key];
}

export function getShortcutAllowRepeat(shortcut: ShortcutConfig): boolean {
return typeof shortcut === "string" ? false : (shortcut.allowRepeat ?? false);
}
Expand Down
6 changes: 3 additions & 3 deletions src/lib/hooks/useArticleNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useShortcut } from "./useShortcut";
import type { KeyboardEvent, RefObject } from "react";
import {
getShortcutAllowRepeat,
getShortcutKey,
getShortcutKeys,
SHORTCUT_KEYS,
} from "~/lib/constants/shortcuts";
import { getScrollContainer } from "~/lib/scroll";
Expand Down Expand Up @@ -299,11 +299,11 @@ export function useArticleNavigation(
return () => container.removeEventListener("focusin", handleFocusIn);
}, [containerRef, selectedIndex, setArticleSelectedElement, scrollToElement]);

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_DOWN), handleArrowDown, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_DOWN), handleArrowDown, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_DOWN),
});

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_UP), handleArrowUp, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_UP), handleArrowUp, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_UP),
});

Expand Down
6 changes: 3 additions & 3 deletions src/lib/hooks/useDebouncedSaveProgress.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef } from "react";
import { useSetProgressMutation } from "~/lib/data/feed-items/mutations";
import { getShortcutKey, SHORTCUT_KEYS } from "~/lib/constants/shortcuts";
import { getShortcutKeys, SHORTCUT_KEYS } from "~/lib/constants/shortcuts";
import { useFeedItemValue } from "~/lib/data/store";

const NAV_KEYS = new Set([
getShortcutKey(SHORTCUT_KEYS.ARROW_UP),
getShortcutKey(SHORTCUT_KEYS.ARROW_DOWN),
...getShortcutKeys(SHORTCUT_KEYS.ARROW_UP),
...getShortcutKeys(SHORTCUT_KEYS.ARROW_DOWN),
]);

const DEBOUNCE_MS = 500;
Expand Down
9 changes: 5 additions & 4 deletions src/lib/hooks/useFeedItemNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import {
getShortcutAllowRepeat,
getShortcutKey,
getShortcutKeys,
SHORTCUT_KEYS,
} from "~/lib/constants/shortcuts";
import { getScrollContainer } from "~/lib/scroll";
Expand Down Expand Up @@ -550,19 +551,19 @@ export function useFeedItemNavigation(
],
);

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_DOWN), handleArrowDown, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_DOWN), handleArrowDown, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_DOWN),
});

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_UP), handleArrowUp, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_UP), handleArrowUp, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_UP),
});

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_RIGHT), handleArrowRight, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_RIGHT), handleArrowRight, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_RIGHT),
});

useShortcut(getShortcutKey(SHORTCUT_KEYS.ARROW_LEFT), handleArrowLeft, {
useShortcut(getShortcutKeys(SHORTCUT_KEYS.ARROW_LEFT), handleArrowLeft, {
allowRepeat: getShortcutAllowRepeat(SHORTCUT_KEYS.ARROW_LEFT),
});

Expand Down
108 changes: 58 additions & 50 deletions src/lib/hooks/useShortcut.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type UseShortcutOptions = {
};

export const useShortcut = (
shortcut: string,
shortcut: string | string[],
callback: (event: KeyboardEvent<Element>) => void,
options: UseShortcutOptions = {},
) => {
Expand All @@ -36,6 +36,12 @@ export const useShortcut = (

const hasOpenDialog = !!useDialogStore((store) => store.dialog);

// Support binding several keys (e.g. an arrow key and its vim-style
// equivalent) to the same handler.
const shortcuts = Array.isArray(shortcut) ? shortcut : [shortcut];
// Joined with a newline (never a valid key) so it serves as a stable dep.
const shortcutsKey = shortcuts.join("\n");

useLayoutEffect(() => {
callbackRef.current = callback;
});
Expand All @@ -62,71 +68,73 @@ export const useShortcut = (
Shift: event.shiftKey,
};

// Handle combined modifier key shortcuts (e.g. pressing Control + D)
if (shortcut.includes("+")) {
const keyArray = shortcut.split("+");
for (const currentShortcut of shortcutsKey.split("\n")) {
// Handle combined modifier key shortcuts (e.g. pressing Control + D)
if (currentShortcut.includes("+")) {
const keyArray = currentShortcut.split("+");

const initialModifierKey = keyArray[0]!;
const initialModifierKey = keyArray[0]!;

const modifierKeys = Object.keys(modifierMap);
const modifierKeys = Object.keys(modifierMap);

// If the first key is a modifier, handle combinations
if (modifierKeys.includes(initialModifierKey)) {
const finalKey = keyArray.pop();
// If the first key is a modifier, handle combinations
if (modifierKeys.includes(initialModifierKey)) {
const finalKey = keyArray.pop();

// Run handler if the modifier(s) + key have both been pressed
const doesEveryModifierMatch = modifierKeys.every((key) => {
// If modifier provided, expect `true`
if (keyArray.includes(key)) {
return modifierMap[key];
}
// If modifier not provided, expect `false`
return !modifierMap[key];
});
// Run handler if the modifier(s) + key have both been pressed
const doesEveryModifierMatch = modifierKeys.every((key) => {
// If modifier provided, expect `true`
if (keyArray.includes(key)) {
return modifierMap[key];
}
// If modifier not provided, expect `false`
return !modifierMap[key];
});

if (doesEveryModifierMatch && finalKey === event.key) {
return callbackRef.current(event);
}
} else {
// If the shortcut doesn't begin with a modifier, it's a sequence
if (keyArray[keyCombo.length] === event.key) {
// Handle final key in the sequence
if (
keyArray[keyArray.length - 1] === event.key &&
keyCombo.length === keyArray.length - 1
) {
// Run handler if the sequence is complete, then reset it
callbackRef.current(event);
if (doesEveryModifierMatch && finalKey === event.key) {
return callbackRef.current(event);
}
} else {
// If the shortcut doesn't begin with a modifier, it's a sequence
if (keyArray[keyCombo.length] === event.key) {
// Handle final key in the sequence
if (
keyArray[keyArray.length - 1] === event.key &&
keyCombo.length === keyArray.length - 1
) {
// Run handler if the sequence is complete, then reset it
callbackRef.current(event);
return setKeyCombo([]);
}

// Add to the sequence
return setKeyCombo((prevCombo) => [...prevCombo, event.key]);
}
if (keyCombo.length > 0) {
// Reset key combo if it doesn't match the sequence
return setKeyCombo([]);
}

// Add to the sequence
return setKeyCombo((prevCombo) => [...prevCombo, event.key]);
}
if (keyCombo.length > 0) {
// Reset key combo if it doesn't match the sequence
return setKeyCombo([]);
}
}
}

// Single key shortcuts (e.g. pressing D)
if (shortcut === event.key) {
// Expect all modifiers to be false
const isEveryModifierFalse = Object.values(modifierMap).every(
(value) => !value,
);
// Single key shortcuts (e.g. pressing D)
if (currentShortcut === event.key) {
// Expect all modifiers to be false
const isEveryModifierFalse = Object.values(modifierMap).every(
(value) => !value,
);

if (!isEveryModifierFalse) {
return;
}
if (!isEveryModifierFalse) {
return;
}

return callbackRef.current(event);
return callbackRef.current(event);
}
}
},
[
hasOpenDialog,
shortcut,
shortcutsKey,
keyCombo.length,
disableTextInputs,
disableDialogs,
Expand Down
Loading