diff options
| author | Anwarul Islam <anwaarulislaam@gmail.com> | 2025-07-18 05:27:40 +0600 |
|---|---|---|
| committer | Anwarul Islam <anwaarulislaam@gmail.com> | 2025-07-18 05:27:40 +0600 |
| commit | 7f609cb86f89773ed0c6064701375f7438819fb4 (patch) | |
| tree | 362e783fd5dca981d11d08d5f6fa81e93b19349d /apps/web/src/constants/keybindings.ts | |
| parent | f1b216848dd9e366aa43144466ed9234bf637832 (diff) | |
| download | opencut-7f609cb86f89773ed0c6064701375f7438819fb4.tar.gz | |
Add action and keybinding management with React hooks
Diffstat (limited to 'apps/web/src/constants/keybindings.ts')
| -rw-r--r-- | apps/web/src/constants/keybindings.ts | 178 |
1 files changed, 178 insertions, 0 deletions
diff --git a/apps/web/src/constants/keybindings.ts b/apps/web/src/constants/keybindings.ts new file mode 100644 index 00000000..8884f43e --- /dev/null +++ b/apps/web/src/constants/keybindings.ts @@ -0,0 +1,178 @@ +import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils"; +import { useEffect } from "react"; +import { ActionWithOptionalArgs, invokeAction } from "./actions"; + +/** + * This variable keeps track whether keybindings are being accepted + * true -> Keybindings are checked + * false -> Key presses are ignored (Keybindings are not checked) + */ +let keybindingsEnabled = true; + +/** + * Alt is also regarded as macOS OPTION (⌥) key + * Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!) + */ +type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl-shift" + | "alt-shift" + | "ctrl-alt" + | "ctrl-alt-shift"; + +/* eslint-disable prettier/prettier */ +// prettier-ignore +type Key = + | "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" + | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" + | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" + | "4" | "5" | "6" | "7" | "8" | "9" | "up" | "down" | "left" + | "right" | "/" | "?" | "." | "enter" | "tab" | "space"; +/* eslint-enable */ + +type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +type SingleCharacterShortcutKey = `${Key}`; + +type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +// Base bindings available on all platforms +const baseBindings: { + [_ in ShortcutKey]?: ActionWithOptionalArgs; +} = { + space: "toggle-play", +}; + +/** + * Get bindings based on the current kernel mode + */ +function getActiveBindings(): typeof baseBindings { + return baseBindings; +} + +export const bindings = getActiveBindings(); + +/** + * A composable that hooks to the caller component's + * lifecycle and hooks to the keyboard events to fire + * the appropriate actions based on keybindings + */ +export function useKeybindingsListener() { + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); +} + +function handleKeyDown(ev: KeyboardEvent) { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; + + const binding = generateKeybindingString(ev); + if (!binding) return; + + const activeBindings = getActiveBindings(); + const boundAction = activeBindings[binding]; + if (!boundAction) return; + + ev.preventDefault(); + invokeAction(boundAction, undefined, "keypress"); +} + +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { + const target = ev.target; + + // We may or may not have a modifier key + const modifierKey = getActiveModifier(ev); + + // We will always have a non-modifier key + const key = getPressedKey(ev); + if (!key) return null; + + // All key combos backed by modifiers are valid shortcuts (whether currently typing or not) + if (modifierKey) { + // If the modifier is shift and the target is an input, we ignore + if ( + modifierKey === "shift" && + isDOMElement(target) && + isTypableElement(target) + ) { + return null; + } + + return `${modifierKey}-${key}`; + } + + // no modifier key here then we do not do anything while on input + if (isDOMElement(target) && isTypableElement(target)) return null; + + // single key while not input + return `${key}`; +} + +function getPressedKey(ev: KeyboardEvent): Key | null { + // Sometimes the property code is not available on the KeyboardEvent object + const key = (ev.key ?? "").toLowerCase(); + + // Check arrow keys + if (key.startsWith("arrow")) { + return key.slice(5) as Key; + } + + // Check for Tab key + if (key === "tab") return "tab"; + + // Check letter keys + const isLetter = key.length === 1 && key >= "a" && key <= "z"; + if (isLetter) return key as Key; + + // Check if number keys + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return key as Key; + + // Check if slash, period or enter + if (key === "/" || key === "." || key === "enter") return key; + + // If no other cases match, this is not a valid key + return null; +} + +function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { + const modifierKeys = { + ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey, + alt: ev.altKey, + shift: ev.shiftKey, + }; + + // active modifier: ctrl | alt | ctrl-alt | ctrl-shift | ctrl-alt-shift | alt-shift + // modiferKeys object's keys are sorted to match the above order + const activeModifier = Object.keys(modifierKeys) + .filter((key) => modifierKeys[key as keyof typeof modifierKeys]) + .join("-"); + + return activeModifier === "" ? null : (activeModifier as ModifierKeys); +} + +/** + * This composable allows for the UI component to be disabled if the component in question is mounted + */ +export function useKeybindingDisabler() { + // TODO: Move to a lock based system that keeps the bindings disabled until all locks are lifted + const disableKeybindings = () => { + keybindingsEnabled = false; + }; + + const enableKeybindings = () => { + keybindingsEnabled = true; + }; + + return { + disableKeybindings, + enableKeybindings, + }; +} |
