From 7f609cb86f89773ed0c6064701375f7438819fb4 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 05:27:40 +0600 Subject: [PATCH] Add action and keybinding management with React hooks --- apps/web/src/constants/actions.ts | 278 ++++++++++++++++++++++++++ apps/web/src/constants/keybindings.ts | 178 +++++++++++++++++ apps/web/src/lib/utils.ts | 55 ++++- 3 files changed, 503 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/constants/actions.ts create mode 100644 apps/web/src/constants/keybindings.ts diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts new file mode 100644 index 00000000..b32ced70 --- /dev/null +++ b/apps/web/src/constants/actions.ts @@ -0,0 +1,278 @@ +/* An `action` is a unique verb that is associated with certain thing that can be done on OpenCut. + * For example, toggling playback or seeking. + */ + +import { + useEffect, + useRef, + useState, + useCallback, + MutableRefObject, +} from "react"; + +// Simple event emitter for action changes +class ActionEmitter { + private listeners: Array<(actions: Action[]) => void> = []; + + subscribe(listener: (actions: Action[]) => void) { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + emit(actions: Action[]) { + this.listeners.forEach((listener) => listener(actions)); + } +} + +const actionEmitter = new ActionEmitter(); + +export type Action = + | "toggle-play" // Toggle play/pause state + | "stop-playback" // Stop playback + | "seek-forward" // Seek forward in playback + | "seek-backward"; // Seek backward in playback + +/** + * Defines the arguments, if present for a given type that is required to be passed on + * invocation and will be passed to action handlers. + * + * This type is supposed to be an object with the key being one of the actions mentioned above. + * The value to the key can be anything. + * If an action has no argument, you do not need to add it to this type. + * + * NOTE: We can't enforce type checks to make sure the key is Action, you + * will know if you got something wrong if there is a type error in this file + */ +type ActionArgsMap = { + "seek-forward": { seconds: number }; // Args needed for seeking forward + "seek-backward": { seconds: number }; // Args needed for seeking backward +}; + +type KeysWithValueUndefined = { + [K in keyof T]: undefined extends T[K] ? K : never; +}[keyof T]; + +/** + * Actions which require arguments for their invocation + */ +export type ActionWithArgs = keyof ActionArgsMap; + +/** + * Actions which optionally takes in arguments for their invocation + */ + +export type ActionWithOptionalArgs = + | ActionWithNoArgs + | KeysWithValueUndefined; + +/** + * Actions which do not require arguments for their invocation + */ +export type ActionWithNoArgs = Exclude; + +/** + * Resolves the argument type for a given Action + */ +type ArgOfHoppAction = A extends ActionWithArgs + ? ActionArgsMap[A] + : undefined; + +/** + * Resolves the action function for a given Action, used by action handler function defs + */ +type ActionFunc = A extends ActionWithArgs + ? (arg: ArgOfHoppAction, trigger?: InvocationTriggers) => void + : (_?: undefined, trigger?: InvocationTriggers) => void; + +type BoundActionList = { + [A in Action]?: Array>; +}; + +const boundActions: BoundActionList = {}; + +let currentActiveActions: Action[] = []; + +function updateActiveActions() { + const newActions = Object.keys(boundActions) as Action[]; + currentActiveActions = newActions; + actionEmitter.emit(newActions); +} + +export function bindAction( + action: A, + handler: ActionFunc +) { + if (boundActions[action]) { + boundActions[action]?.push(handler); + } else { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = [handler] as any; + } + + updateActiveActions(); +} + +export type InvocationTriggers = "keypress" | "mouseclick"; + +type InvokeActionFunc = { + ( + action: ActionWithOptionalArgs, + args?: undefined, + trigger?: InvocationTriggers + ): void; + (action: A, args: ActionArgsMap[A]): void; +}; + +/** + * Invokes an action, triggering action handlers if any registered. + * The second and third arguments are optional + * @param action The action to fire + * @param args The argument passed to the action handler. Optional if action has no args required + * @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick) + */ +export const invokeAction: InvokeActionFunc = ( + action: A, + args?: ArgOfHoppAction, + trigger?: InvocationTriggers +) => { + boundActions[action]?.forEach((handler) => (handler as any)(args, trigger)); +}; + +export function unbindAction( + action: A, + handler: ActionFunc +) { + // 'any' assertion because TypeScript doesn't seem to be able to figure out the links. + boundActions[action] = boundActions[action]?.filter( + (x) => x !== handler + ) as any; + + if (boundActions[action]?.length === 0) { + delete boundActions[action]; + } + + updateActiveActions(); +} + +/** + * Returns whether a given action is bound at a given time + * + * @param action The action to check + */ +export function isActionBound(action: Action): boolean { + return !!boundActions[action]; +} + +/** + * A React hook that defines a component can handle a given + * Action. The handler will be bound when the component is mounted + * and unbound when the component is unmounted. + * @param action The action to be bound + * @param handler The function to be called when the action is invoked + * @param isActive A ref that indicates whether the action is active + */ +export function useActionHandler( + action: A, + handler: ActionFunc, + isActive: MutableRefObject | boolean | undefined = undefined +) { + const handlerRef = useRef(handler); + const [isBound, setIsBound] = useState(false); + + // Update handler ref when handler changes + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + // Create a stable handler wrapper + const stableHandler = useCallback( + (args: any, trigger?: InvocationTriggers) => { + (handlerRef.current as any)(args, trigger); + }, + [] + ) as ActionFunc; + + useEffect(() => { + const shouldBind = + isActive === undefined || + (typeof isActive === "boolean" ? isActive : isActive.current); + + if (shouldBind && !isBound) { + bindAction(action, stableHandler); + setIsBound(true); + } else if (!shouldBind && isBound) { + unbindAction(action, stableHandler); + setIsBound(false); + } + + return () => { + if (isBound) { + unbindAction(action, stableHandler); + setIsBound(false); + } + }; + }, [action, stableHandler, isActive, isBound]); + + // Handle ref-based isActive changes + useEffect(() => { + if (isActive && typeof isActive === "object" && "current" in isActive) { + const checkActive = () => { + const shouldBind = isActive.current; + if (shouldBind && !isBound) { + bindAction(action, stableHandler); + setIsBound(true); + } else if (!shouldBind && isBound) { + unbindAction(action, stableHandler); + setIsBound(false); + } + }; + + // Initial check + checkActive(); + } + }, [action, stableHandler, isActive, isBound]); +} + +/** + * A React hook that returns the current list of active actions + * and re-renders when the list changes + */ +export function useActiveActions(): Action[] { + const [activeActions, setActiveActions] = useState([]); + + useEffect(() => { + // Set initial value + setActiveActions(currentActiveActions); + + // Subscribe to changes + const unsubscribe = actionEmitter.subscribe(setActiveActions); + return unsubscribe; + }, []); + + return activeActions; +} + +/** + * A React hook that returns whether a specific action is currently bound + * and re-renders when the binding state changes + */ +export function useIsActionBound(action: Action): boolean { + const [isBound, setIsBound] = useState(() => isActionBound(action)); + + useEffect(() => { + const updateBoundState = () => { + setIsBound(isActionBound(action)); + }; + + // Set initial value + updateBoundState(); + + // Subscribe to changes + const unsubscribe = actionEmitter.subscribe(updateBoundState); + return unsubscribe; + }, [action]); + + return isBound; +} 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, + }; +} diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index dd73059f..188ec261 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -13,7 +13,10 @@ export function cn(...inputs: ClassValue[]) { */ export function generateUUID(): string { // Use the native crypto.randomUUID if available - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + if ( + typeof crypto !== "undefined" && + typeof crypto.randomUUID === "function" + ) { return crypto.randomUUID(); } @@ -26,13 +29,49 @@ export function generateUUID(): string { // Set variant 10xxxxxx bytes[8] = (bytes[8] & 0x3f) | 0x80; - const hex = [...bytes].map(b => b.toString(16).padStart(2, '0')); + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); return ( - hex.slice(0, 4).join('') + '-' + - hex.slice(4, 6).join('') + '-' + - hex.slice(6, 8).join('') + '-' + - hex.slice(8, 10).join('') + '-' + - hex.slice(10, 16).join('') + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") ); -} \ No newline at end of file +} + +export function isDOMElement(el: any): el is HTMLElement { + return !!el && (el instanceof Element || el instanceof HTMLElement); +} + +export function isTypableElement(el: HTMLElement): boolean { + // If content editable, then it is editable + if (el.isContentEditable) return true; + + // If element is an input and the input is enabled, then it is typable + if (el.tagName === "INPUT") { + return !(el as HTMLInputElement).disabled; + } + // If element is a textarea and the input is enabled, then it is typable + if (el.tagName === "TEXTAREA") { + return !(el as HTMLTextAreaElement).disabled; + } + + return false; +} + +export function isAppleDevice() { + return /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); +} + +export function getPlatformSpecialKey() { + return isAppleDevice() ? "⌘" : "Ctrl"; +} + +export function getPlatformAlternateKey() { + return isAppleDevice() ? "⌥" : "Alt"; +}