From 7f609cb86f89773ed0c6064701375f7438819fb4 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 05:27:40 +0600 Subject: Add action and keybinding management with React hooks --- apps/web/src/constants/actions.ts | 278 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 apps/web/src/constants/actions.ts (limited to 'apps/web/src/constants/actions.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; +} -- cgit v1.3.1 From 1afc51eb9dfe1e26a670871fafca6df5a2fbd426 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:07:52 +0600 Subject: Enhance action types and arguments for playback controls --- apps/web/src/constants/actions.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) (limited to 'apps/web/src/constants/actions.ts') diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index b32ced70..95a1e645 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -32,7 +32,20 @@ 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 + | "seek-backward" // Seek backward in playback + | "frame-step-forward" // Step forward by one frame + | "frame-step-backward" // Step backward by one frame + | "jump-forward" // Jump forward by 5 seconds + | "jump-backward" // Jump backward by 5 seconds + | "goto-start" // Go to timeline start + | "goto-end" // Go to timeline end + | "split-element" // Split element at current time + | "delete-selected" // Delete selected elements + | "select-all" // Select all elements + | "duplicate-selected" // Duplicate selected element + | "toggle-snapping" // Toggle snapping + | "undo" // Undo last action + | "redo"; // Redo last undone action /** * Defines the arguments, if present for a given type that is required to be passed on @@ -46,8 +59,10 @@ export type Action = * 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 + "seek-forward": { seconds: number } | undefined; // Args needed for seeking forward (default: 1) + "seek-backward": { seconds: number } | undefined; // Args needed for seeking backward (default: 1) + "jump-forward": { seconds: number } | undefined; // Args needed for jumping forward (default: 5) + "jump-backward": { seconds: number } | undefined; // Args needed for jumping backward (default: 5) }; type KeysWithValueUndefined = { -- cgit v1.3.1 From c3caefc001e1ea6a7e5626acb1fe1054fa00a56a Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 08:30:42 +0600 Subject: feat: enhance keybinding import validation and improve error handling --- apps/web/src/components/keybinding-editor.tsx | 13 ++++++++++++- apps/web/src/constants/actions.ts | 22 +++++++++++----------- apps/web/src/stores/keybindings-store.ts | 8 ++++++++ 3 files changed, 31 insertions(+), 12 deletions(-) (limited to 'apps/web/src/constants/actions.ts') diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx index 8735c504..bc98c5fd 100644 --- a/apps/web/src/components/keybinding-editor.tsx +++ b/apps/web/src/components/keybinding-editor.tsx @@ -224,10 +224,21 @@ export const KeybindingEditor = ({ reader.onload = (e) => { try { const config = JSON.parse(e.target?.result as string); + // Validate config structure + if (!config || typeof config !== "object") { + throw new Error("Invalid configuration format"); + } + + // Validate each keybinding + for (const [key, action] of Object.entries(config)) { + if (typeof key !== "string" || typeof action !== "string") { + throw new Error(`Invalid keybinding: ${key} -> ${action}`); + } + } importKeybindings(config); toast.success("Keybindings imported successfully"); } catch (error) { - toast.error("Failed to import keybindings file"); + toast.error(`Failed to import keybindings: ${error}`); } }; reader.readAsText(file); diff --git a/apps/web/src/constants/actions.ts b/apps/web/src/constants/actions.ts index 95a1e645..5f1883f1 100644 --- a/apps/web/src/constants/actions.ts +++ b/apps/web/src/constants/actions.ts @@ -233,19 +233,19 @@ export function useActionHandler( // Handle ref-based isActive changes useEffect(() => { if (isActive && typeof isActive === "object" && "current" in isActive) { - const checkActive = () => { + // Poll for ref changes + const interval = setInterval(() => { const shouldBind = isActive.current; - if (shouldBind && !isBound) { - bindAction(action, stableHandler); - setIsBound(true); - } else if (!shouldBind && isBound) { - unbindAction(action, stableHandler); - setIsBound(false); + if (shouldBind !== isBound) { + if (shouldBind) { + bindAction(action, stableHandler); + } else { + unbindAction(action, stableHandler); + } + setIsBound(shouldBind); } - }; - - // Initial check - checkActive(); + }, 100); + return () => clearInterval(interval); } }, [action, stableHandler, isActive, isBound]); } diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 6a72423f..6d9f5d54 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -112,6 +112,13 @@ export const useKeybindingsStore = create()( }, importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key, action] of Object.entries(config)) { + // Validate the key format + if (typeof key !== "string" || key.length === 0) { + throw new Error(`Invalid key format: ${key}`); + } + } set({ keybindings: { ...config }, isCustomized: true, @@ -198,6 +205,7 @@ function getPressedKey(ev: KeyboardEvent): string | null { // Check for special keys if (key === "tab") return "tab"; + if (key === " " || key === "space") return "space"; if (key === "home") return "home"; if (key === "end") return "end"; if (key === "delete") return "delete"; -- cgit v1.3.1