From c101f1d70eea4bf1e4119d9e8d725a65d257998e Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:00:05 -0400 Subject: [PATCH 1/2] add vim style movement --- src/lib/constants/shortcuts.ts | 47 +++++++++- src/lib/hooks/useArticleNavigation.ts | 6 +- src/lib/hooks/useDebouncedSaveProgress.ts | 6 +- src/lib/hooks/useFeedItemNavigation.ts | 9 +- src/lib/hooks/useShortcut.ts | 108 ++++++++++++---------- 5 files changed, 111 insertions(+), 65 deletions(-) diff --git a/src/lib/constants/shortcuts.ts b/src/lib/constants/shortcuts.ts index 0438cdb8..45427075 100644 --- a/src/lib/constants/shortcuts.ts +++ b/src/lib/constants/shortcuts.ts @@ -1,3 +1,11 @@ +// Vim-style movement keys that mirror the arrow keys (h/j/k/l). +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", @@ -20,10 +28,26 @@ 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: "]", @@ -31,12 +55,25 @@ export const SHORTCUT_KEYS = { 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); } diff --git a/src/lib/hooks/useArticleNavigation.ts b/src/lib/hooks/useArticleNavigation.ts index 4a4e79ec..e422aa9a 100644 --- a/src/lib/hooks/useArticleNavigation.ts +++ b/src/lib/hooks/useArticleNavigation.ts @@ -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"; @@ -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), }); diff --git a/src/lib/hooks/useDebouncedSaveProgress.ts b/src/lib/hooks/useDebouncedSaveProgress.ts index 69ddf8d8..59f14d91 100644 --- a/src/lib/hooks/useDebouncedSaveProgress.ts +++ b/src/lib/hooks/useDebouncedSaveProgress.ts @@ -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; diff --git a/src/lib/hooks/useFeedItemNavigation.ts b/src/lib/hooks/useFeedItemNavigation.ts index 37c6f3f0..cc62a149 100644 --- a/src/lib/hooks/useFeedItemNavigation.ts +++ b/src/lib/hooks/useFeedItemNavigation.ts @@ -21,6 +21,7 @@ import { import { getShortcutAllowRepeat, getShortcutKey, + getShortcutKeys, SHORTCUT_KEYS, } from "~/lib/constants/shortcuts"; import { getScrollContainer } from "~/lib/scroll"; @@ -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), }); diff --git a/src/lib/hooks/useShortcut.ts b/src/lib/hooks/useShortcut.ts index 33d56b1e..a0c1327c 100644 --- a/src/lib/hooks/useShortcut.ts +++ b/src/lib/hooks/useShortcut.ts @@ -22,7 +22,7 @@ type UseShortcutOptions = { }; export const useShortcut = ( - shortcut: string, + shortcut: string | string[], callback: (event: KeyboardEvent) => void, options: UseShortcutOptions = {}, ) => { @@ -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; }); @@ -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, From c1810de9ca3222ec5c6436086a49c4d73a7d8bae Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:02:04 -0400 Subject: [PATCH 2/2] remove comment --- src/lib/constants/shortcuts.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/constants/shortcuts.ts b/src/lib/constants/shortcuts.ts index 45427075..6fbbb454 100644 --- a/src/lib/constants/shortcuts.ts +++ b/src/lib/constants/shortcuts.ts @@ -1,4 +1,3 @@ -// Vim-style movement keys that mirror the arrow keys (h/j/k/l). export const VIM_MOVEMENT_KEYS = { UP: "k", DOWN: "j",