summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnwarul Islam <anwaarulislaam@gmail.com>2025-07-18 05:27:40 +0600
committerAnwarul Islam <anwaarulislaam@gmail.com>2025-07-18 05:27:40 +0600
commit7f609cb86f89773ed0c6064701375f7438819fb4 (patch)
tree362e783fd5dca981d11d08d5f6fa81e93b19349d
parentf1b216848dd9e366aa43144466ed9234bf637832 (diff)
downloadopencut-7f609cb86f89773ed0c6064701375f7438819fb4.tar.gz
Add action and keybinding management with React hooks
-rw-r--r--apps/web/src/constants/actions.ts278
-rw-r--r--apps/web/src/constants/keybindings.ts178
-rw-r--r--apps/web/src/lib/utils.ts55
3 files changed, 503 insertions, 8 deletions
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<T> = {
+ [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<ActionArgsMap>;
+
+/**
+ * Actions which do not require arguments for their invocation
+ */
+export type ActionWithNoArgs = Exclude<Action, ActionWithArgs>;
+
+/**
+ * Resolves the argument type for a given Action
+ */
+type ArgOfHoppAction<A extends Action> = A extends ActionWithArgs
+ ? ActionArgsMap[A]
+ : undefined;
+
+/**
+ * Resolves the action function for a given Action, used by action handler function defs
+ */
+type ActionFunc<A extends Action> = A extends ActionWithArgs
+ ? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
+ : (_?: undefined, trigger?: InvocationTriggers) => void;
+
+type BoundActionList = {
+ [A in Action]?: Array<ActionFunc<A>>;
+};
+
+const boundActions: BoundActionList = {};
+
+let currentActiveActions: Action[] = [];
+
+function updateActiveActions() {
+ const newActions = Object.keys(boundActions) as Action[];
+ currentActiveActions = newActions;
+ actionEmitter.emit(newActions);
+}
+
+export function bindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ 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;
+ <A extends ActionWithArgs>(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 = <A extends Action>(
+ action: A,
+ args?: ArgOfHoppAction<A>,
+ trigger?: InvocationTriggers
+) => {
+ boundActions[action]?.forEach((handler) => (handler as any)(args, trigger));
+};
+
+export function unbindAction<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>
+) {
+ // '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<A extends Action>(
+ action: A,
+ handler: ActionFunc<A>,
+ isActive: MutableRefObject<boolean> | 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<A>;
+
+ 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<Action[]>([]);
+
+ 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";
+}