From 7f609cb86f89773ed0c6064701375f7438819fb4 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 05:27:40 +0600 Subject: [PATCH 01/12] 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"; +} From 01b11226a3117b69923020e9fc9470f42fa4d116 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:07:47 +0600 Subject: [PATCH 02/12] Add additional keybindings for navigation and editing actions --- apps/web/src/constants/keybindings.ts | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/web/src/constants/keybindings.ts b/apps/web/src/constants/keybindings.ts index 8884f43e..a5cdea1c 100644 --- a/apps/web/src/constants/keybindings.ts +++ b/apps/web/src/constants/keybindings.ts @@ -29,7 +29,8 @@ type Key = | "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"; + | "right" | "/" | "?" | "." | "enter" | "tab" | "space" | "home" + | "end" | "delete" | "backspace"; /* eslint-enable */ type ModifierBasedShortcutKey = `${ModifierKeys}-${Key}`; @@ -43,6 +44,24 @@ const baseBindings: { [_ in ShortcutKey]?: ActionWithOptionalArgs; } = { space: "toggle-play", + j: "seek-backward", + k: "toggle-play", + l: "seek-forward", + left: "frame-step-backward", + right: "frame-step-forward", + "shift-left": "jump-backward", + "shift-right": "jump-forward", + home: "goto-start", + end: "goto-end", + s: "split-element", + n: "toggle-snapping", + "ctrl-a": "select-all", + "ctrl-d": "duplicate-selected", + "ctrl-z": "undo", + "ctrl-shift-z": "redo", + "ctrl-y": "redo", + delete: "delete-selected", + backspace: "delete-selected", }; /** @@ -81,6 +100,7 @@ function handleKeyDown(ev: KeyboardEvent) { if (!boundAction) return; ev.preventDefault(); + invokeAction(boundAction, undefined, "keypress"); } @@ -127,6 +147,12 @@ function getPressedKey(ev: KeyboardEvent): Key | null { // Check for Tab key if (key === "tab") return "tab"; + // Check for special keys + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; + // Check letter keys const isLetter = key.length === 1 && key >= "a" && key <= "z"; if (isLetter) return key as Key; From 1afc51eb9dfe1e26a670871fafca6df5a2fbd426 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:07:52 +0600 Subject: [PATCH 03/12] Enhance action types and arguments for playback controls --- apps/web/src/constants/actions.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 = { From 7eda7975fcc06a9fa3d88975c84ccc07748298cb Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:08:46 +0600 Subject: [PATCH 04/12] Implement editor actions for playback and timeline editing --- apps/web/src/hooks/use-editor-actions.ts | 162 +++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 apps/web/src/hooks/use-editor-actions.ts diff --git a/apps/web/src/hooks/use-editor-actions.ts b/apps/web/src/hooks/use-editor-actions.ts new file mode 100644 index 00000000..e0d9b9eb --- /dev/null +++ b/apps/web/src/hooks/use-editor-actions.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect } from "react"; +import { useActionHandler } from "@/constants/actions"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; +import { toast } from "sonner"; + +export function useEditorActions() { + const { + tracks, + selectedElements, + clearSelectedElements, + setSelectedElements, + removeElementFromTrack, + splitElement, + addElementToTrack, + snappingEnabled, + toggleSnapping, + undo, + redo, + } = useTimelineStore(); + + const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); + const { activeProject } = useProjectStore(); + + // Playback actions + useActionHandler("toggle-play", () => { + toggle(); + }); + + useActionHandler("stop-playback", () => { + if (isPlaying) { + toggle(); + } + seek(0); + }); + + useActionHandler("seek-forward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("seek-backward", (args) => { + const seconds = args?.seconds ?? 1; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("frame-step-forward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.min(duration, currentTime + 1 / projectFps)); + }); + + useActionHandler("frame-step-backward", () => { + const projectFps = activeProject?.fps || 30; + seek(Math.max(0, currentTime - 1 / projectFps)); + }); + + useActionHandler("jump-forward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.min(duration, currentTime + seconds)); + }); + + useActionHandler("jump-backward", (args) => { + const seconds = args?.seconds ?? 5; + seek(Math.max(0, currentTime - seconds)); + }); + + useActionHandler("goto-start", () => { + seek(0); + }); + + useActionHandler("goto-end", () => { + seek(duration); + }); + + // Timeline editing actions + useActionHandler("split-element", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to split"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitElement(trackId, elementId, currentTime); + } else { + toast.error("Playhead must be within selected element"); + } + } + }); + + useActionHandler("delete-selected", () => { + if (selectedElements.length === 0) { + toast.error("No elements selected"); + return; + } + selectedElements.forEach( + ({ trackId, elementId }: { trackId: string; elementId: string }) => { + removeElementFromTrack(trackId, elementId); + } + ); + clearSelectedElements(); + }); + + useActionHandler("select-all", () => { + const allElements = tracks.flatMap((track: any) => + track.elements.map((element: any) => ({ + trackId: track.id, + elementId: element.id, + })) + ); + setSelectedElements(allElements); + }); + + useActionHandler("duplicate-selected", () => { + if (selectedElements.length !== 1) { + toast.error("Select exactly one element to duplicate"); + return; + } + + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t: any) => t.id === trackId); + const element = track?.elements.find((el: any) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, + }); + } + }); + + useActionHandler("toggle-snapping", () => { + toggleSnapping(); + }); + + // History actions + useActionHandler("undo", () => { + undo(); + }); + + useActionHandler("redo", () => { + redo(); + }); +} From f3bffae7ea015ef18b36b1b447e2c857d898d522 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:08:57 +0600 Subject: [PATCH 05/12] Refactor editor provider to integrate keybinding management and action handlers --- apps/web/src/components/editor-provider.tsx | 25 ++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index d970f86d..ae1bb1a5 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -3,7 +3,11 @@ import { useEffect } from "react"; import { Loader2 } from "lucide-react"; import { useEditorStore } from "@/stores/editor-store"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { + useKeybindingsListener, + useKeybindingDisabler, +} from "@/constants/keybindings"; +import { useEditorActions } from "@/hooks/use-editor-actions"; interface EditorProviderProps { children: React.ReactNode; @@ -11,11 +15,22 @@ interface EditorProviderProps { export function EditorProvider({ children }: EditorProviderProps) { const { isInitializing, isPanelsReady, initializeApp } = useEditorStore(); + const { disableKeybindings, enableKeybindings } = useKeybindingDisabler(); - useKeyboardShortcuts({ - enabled: !isInitializing && isPanelsReady, - context: "editor", - }); + // Set up action handlers + useEditorActions(); + + // Set up keybinding listener + useKeybindingsListener(); + + // Disable keybindings when initializing + useEffect(() => { + if (isInitializing || !isPanelsReady) { + disableKeybindings(); + } else { + enableKeybindings(); + } + }, [isInitializing, isPanelsReady, disableKeybindings, enableKeybindings]); useEffect(() => { initializeApp(); From d328e009d4a5c4e77b7724168661b8a8a26d9e85 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:09:05 +0600 Subject: [PATCH 06/12] Refactor keyboard shortcuts management: introduce useKeyboardShortcutsHelp hook and update KeyBadge and ShortcutItem components --- .../components/keyboard-shortcuts-help.tsx | 11 +- .../src/hooks/use-keyboard-shortcuts-help.ts | 121 +++++++ apps/web/src/hooks/use-keyboard-shortcuts.ts | 334 ------------------ 3 files changed, 128 insertions(+), 338 deletions(-) create mode 100644 apps/web/src/hooks/use-keyboard-shortcuts-help.ts delete mode 100644 apps/web/src/hooks/use-keyboard-shortcuts.ts diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index 936254be..c908b9ce 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -12,7 +12,10 @@ import { } from "./ui/dialog"; import { Badge } from "./ui/badge"; import { Keyboard } from "lucide-react"; -import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { + useKeyboardShortcutsHelp, + KeyboardShortcut, +} from "@/hooks/use-keyboard-shortcuts-help"; const KeyBadge = ({ keyName }: { keyName: string }) => { // Replace common key names with symbols or friendly names @@ -34,7 +37,7 @@ const KeyBadge = ({ keyName }: { keyName: string }) => { ); }; -const ShortcutItem = ({ shortcut }: { shortcut: any }) => { +const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => { // Filter out lowercase duplicates for display - if both "j" and "J" exist, only show "J" const displayKeys = shortcut.keys.filter((key: string) => { const lowerKey = key.toLowerCase(); @@ -86,8 +89,8 @@ const ShortcutItem = ({ shortcut }: { shortcut: any }) => { export const KeyboardShortcutsHelp = () => { const [open, setOpen] = useState(false); - // Get shortcuts from centralized hook (disabled so it doesn't add event listeners) - const { shortcuts } = useKeyboardShortcuts({ enabled: false }); + // Get shortcuts from centralized hook + const { shortcuts } = useKeyboardShortcutsHelp(); const categories = Array.from(new Set(shortcuts.map((s) => s.category))); diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts new file mode 100644 index 00000000..cac68524 --- /dev/null +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -0,0 +1,121 @@ +"use client"; + +import { useMemo } from "react"; +import { bindings } from "@/constants/keybindings"; +import { Action } from "@/constants/actions"; + +export interface KeyboardShortcut { + id: string; + keys: string[]; + description: string; + category: string; + action: Action; + icon?: React.ReactNode; +} + +// Map actions to their descriptions and categories +const actionDescriptions: Record< + Action, + { description: string; category: string } +> = { + "toggle-play": { description: "Play/Pause", category: "Playback" }, + "stop-playback": { description: "Stop playback", category: "Playback" }, + "seek-forward": { + description: "Seek forward 1 second", + category: "Playback", + }, + "seek-backward": { + description: "Seek backward 1 second", + category: "Playback", + }, + "frame-step-forward": { + description: "Frame step forward", + category: "Navigation", + }, + "frame-step-backward": { + description: "Frame step backward", + category: "Navigation", + }, + "jump-forward": { + description: "Jump forward 5 seconds", + category: "Navigation", + }, + "jump-backward": { + description: "Jump backward 5 seconds", + category: "Navigation", + }, + "goto-start": { description: "Go to timeline start", category: "Navigation" }, + "goto-end": { description: "Go to timeline end", category: "Navigation" }, + "split-element": { + description: "Split element at playhead", + category: "Editing", + }, + "delete-selected": { + description: "Delete selected elements", + category: "Editing", + }, + "select-all": { description: "Select all elements", category: "Selection" }, + "duplicate-selected": { + description: "Duplicate selected element", + category: "Selection", + }, + "toggle-snapping": { description: "Toggle snapping", category: "Editing" }, + undo: { description: "Undo", category: "History" }, + redo: { description: "Redo", category: "History" }, +}; + +// Convert key binding format to display format +const formatKey = (key: string): string => { + return key + .replace("ctrl", "Cmd") + .replace("alt", "Alt") + .replace("shift", "Shift") + .replace("left", "ArrowLeft") + .replace("right", "ArrowRight") + .replace("up", "ArrowUp") + .replace("down", "ArrowDown") + .replace("space", "Space") + .replace("home", "Home") + .replace("end", "End") + .replace("delete", "Delete") + .replace("backspace", "Backspace") + .replace("-", "+"); +}; + +export const useKeyboardShortcutsHelp = () => { + const shortcuts = useMemo(() => { + const result: KeyboardShortcut[] = []; + + // Group keybindings by action + const actionToKeys: Record = {} as any; + + Object.entries(bindings).forEach(([key, action]) => { + if (action) { + if (!actionToKeys[action]) { + actionToKeys[action] = []; + } + actionToKeys[action].push(formatKey(key)); + } + }); + + // Convert to shortcuts format + Object.entries(actionToKeys).forEach(([action, keys]) => { + const actionInfo = actionDescriptions[action as Action]; + if (actionInfo) { + result.push({ + id: action, + keys, + description: actionInfo.description, + category: actionInfo.category, + action: action as Action, + }); + } + }); + + return result; + }, []); + + return { + shortcuts, + }; +}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts.ts b/apps/web/src/hooks/use-keyboard-shortcuts.ts deleted file mode 100644 index 84cb78f8..00000000 --- a/apps/web/src/hooks/use-keyboard-shortcuts.ts +++ /dev/null @@ -1,334 +0,0 @@ -"use client"; - -import { useEffect, useCallback } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useProjectStore } from "@/stores/project-store"; -import { toast } from "sonner"; - -export interface KeyboardShortcut { - id: string; - keys: string[]; - description: string; - category: string; - action: () => void; - enabled?: boolean; - requiresSelection?: boolean; - icon?: React.ReactNode; -} - -interface UseKeyboardShortcutsOptions { - enabled?: boolean; - context?: "global" | "timeline" | "editor"; -} - -export const useKeyboardShortcuts = ( - options: UseKeyboardShortcutsOptions = {} -) => { - const { enabled = true, context = "editor" } = options; - - const { - tracks, - selectedElements, - clearSelectedElements, - setSelectedElements, - removeElementFromTrack, - splitElement, - addElementToTrack, - snappingEnabled, - toggleSnapping, - undo, - redo, - } = useTimelineStore(); - - const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); - - const { activeProject } = useProjectStore(); - - // Check if user is typing in an input field - const isInputFocused = useCallback(() => { - const activeElement = document.activeElement as HTMLElement; - return ( - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.contentEditable === "true") - ); - }, []); - - // Define all shortcuts in one place - const shortcuts: KeyboardShortcut[] = [ - // Playback Controls - { - id: "play-pause", - keys: ["Space"], - description: "Play/Pause", - category: "Playback", - action: () => { - toggle(); - }, - }, - { - id: "rewind", - keys: ["j", "J"], - description: "Rewind 1 second", - category: "Playback", - action: () => { - seek(Math.max(0, currentTime - 1)); - }, - }, - { - id: "play-pause-alt", - keys: ["k", "K"], - description: "Play/Pause (alternative)", - category: "Playback", - action: () => { - toggle(); - }, - }, - { - id: "fast-forward", - keys: ["l", "L"], - description: "Fast forward 1 second", - category: "Playback", - action: () => { - seek(Math.min(duration, currentTime + 1)); - }, - }, - - // Navigation - { - id: "frame-backward", - keys: ["ArrowLeft"], - description: "Frame step backward", - category: "Navigation", - action: () => { - const projectFps = activeProject?.fps || 30; - seek(Math.max(0, currentTime - 1 / projectFps)); - }, - }, - { - id: "frame-forward", - keys: ["ArrowRight"], - description: "Frame step forward", - category: "Navigation", - action: () => { - const projectFps = activeProject?.fps || 30; - seek(Math.min(duration, currentTime + 1 / projectFps)); - }, - }, - { - id: "jump-backward", - keys: ["Shift+ArrowLeft"], - description: "Jump back 5 seconds", - category: "Navigation", - action: () => { - seek(Math.max(0, currentTime - 5)); - }, - }, - { - id: "jump-forward", - keys: ["Shift+ArrowRight"], - description: "Jump forward 5 seconds", - category: "Navigation", - action: () => { - seek(Math.min(duration, currentTime + 5)); - }, - }, - { - id: "goto-start", - keys: ["Home"], - description: "Go to timeline start", - category: "Navigation", - action: () => { - seek(0); - }, - }, - { - id: "goto-end", - keys: ["End"], - description: "Go to timeline end", - category: "Navigation", - action: () => { - seek(duration); - }, - }, - - // Editing - { - id: "split-element", - keys: ["s", "S"], - description: "Split element at playhead", - category: "Editing", - requiresSelection: true, - action: () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to split"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); - - if (element) { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitElement(trackId, elementId, currentTime); - } else { - toast.error("Playhead must be within selected element"); - } - } - }, - }, - { - id: "delete-elements", - keys: ["Delete", "Backspace"], - description: "Delete selected elements", - category: "Editing", - requiresSelection: true, - action: () => { - if (selectedElements.length === 0) { - toast.error("No elements selected"); - return; - } - selectedElements.forEach( - ({ trackId, elementId }: { trackId: string; elementId: string }) => { - removeElementFromTrack(trackId, elementId); - } - ); - clearSelectedElements(); - }, - }, - { - id: "toggle-snapping", - keys: ["n", "N"], - description: "Toggle snapping", - category: "Editing", - action: () => { - toggleSnapping(); - }, - }, - - // Selection & Organization - { - id: "select-all", - keys: ["Cmd+a", "Ctrl+a"], - description: "Select all elements", - category: "Selection", - action: () => { - const allElements = tracks.flatMap((track: any) => - track.elements.map((element: any) => ({ - trackId: track.id, - elementId: element.id, - })) - ); - setSelectedElements(allElements); - }, - }, - { - id: "duplicate-element", - keys: ["Cmd+d", "Ctrl+d"], - description: "Duplicate selected element", - category: "Selection", - requiresSelection: true, - action: () => { - if (selectedElements.length !== 1) { - toast.error("Select exactly one element to duplicate"); - return; - } - - const { trackId, elementId } = selectedElements[0]; - const track = tracks.find((t: any) => t.id === trackId); - const element = track?.elements.find((el: any) => el.id === elementId); - - if (element) { - const newStartTime = - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1; - const { id, ...elementWithoutId } = element; - - addElementToTrack(trackId, { - ...elementWithoutId, - startTime: newStartTime, - }); - } - }, - }, - - // History - { - id: "undo", - keys: ["Cmd+z", "Ctrl+z"], - description: "Undo", - category: "History", - action: () => { - undo(); - }, - }, - { - id: "redo", - keys: ["Cmd+Shift+z", "Ctrl+Shift+z", "Cmd+y", "Ctrl+y"], - description: "Redo", - category: "History", - action: () => { - redo(); - }, - }, - ]; - - // Parse keyboard event to match against shortcuts - const parseKeyboardEvent = useCallback((e: KeyboardEvent): string => { - const parts: string[] = []; - - if (e.metaKey || e.ctrlKey) parts.push(e.metaKey ? "Cmd" : "Ctrl"); - if (e.shiftKey) parts.push("Shift"); - if (e.altKey) parts.push("Alt"); - - parts.push(e.key); - - return parts.join("+"); - }, []); - - // Handle keyboard events - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - if (!enabled || isInputFocused()) return; - - const keyCombo = parseKeyboardEvent(e); - const shortcut = shortcuts.find((s) => - s.keys.some((key) => key === keyCombo || key === e.key) - ); - - if (shortcut) { - // Check if shortcut requires selection - if (shortcut.requiresSelection && selectedElements.length === 0) { - return; - } - - e.preventDefault(); - shortcut.action(); - } - }, - [enabled, shortcuts, selectedElements, parseKeyboardEvent, isInputFocused] - ); - - // Set up event listener - useEffect(() => { - if (!enabled) return; - - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [enabled, handleKeyDown]); - - // Return shortcuts for help component - return { - shortcuts: shortcuts.filter((s) => s.enabled !== false), - enabled, - }; -}; From 4137747abacd74a1e75702c93ab403e7bd2b218b Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:35:31 +0600 Subject: [PATCH 07/12] feat: add keybindings store with default configurations and utility functions --- apps/web/src/stores/keybindings-store.ts | 223 +++++++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 apps/web/src/stores/keybindings-store.ts diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts new file mode 100644 index 00000000..ddfb270b --- /dev/null +++ b/apps/web/src/stores/keybindings-store.ts @@ -0,0 +1,223 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { ActionWithOptionalArgs } from "@/constants/actions"; +import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils"; + +export type ShortcutKey = string; + +export interface KeybindingConfig { + [key: ShortcutKey]: ActionWithOptionalArgs; +} + +// Default keybindings configuration +export const defaultKeybindings: KeybindingConfig = { + space: "toggle-play", + j: "seek-backward", + k: "toggle-play", + l: "seek-forward", + left: "frame-step-backward", + right: "frame-step-forward", + "shift-left": "jump-backward", + "shift-right": "jump-forward", + home: "goto-start", + end: "goto-end", + s: "split-element", + n: "toggle-snapping", + "ctrl-a": "select-all", + "ctrl-d": "duplicate-selected", + "ctrl-z": "undo", + "ctrl-shift-z": "redo", + "ctrl-y": "redo", + delete: "delete-selected", + backspace: "delete-selected", +}; + +export interface KeybindingConflict { + key: string; + existingAction: ActionWithOptionalArgs; + newAction: ActionWithOptionalArgs; +} + +interface KeybindingsState { + keybindings: KeybindingConfig; + isCustomized: boolean; + + // Actions + updateKeybinding: (key: string, action: ActionWithOptionalArgs) => void; + removeKeybinding: (key: string) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + + // Validation + validateKeybinding: ( + key: string, + action: ActionWithOptionalArgs + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: ActionWithOptionalArgs) => string[]; + + // Utility + getKeybindingString: (ev: KeyboardEvent) => string | null; +} + +export const useKeybindingsStore = create()( + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + + updateKeybinding: (key: string, action: ActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key: string) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, + + importKeybindings: (config: KeybindingConfig) => { + set({ + keybindings: { ...config }, + isCustomized: true, + }); + }, + + exportKeybindings: () => { + return get().keybindings; + }, + + validateKeybinding: (key: string, action: ActionWithOptionalArgs) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; + + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + + return null; + }, + + getKeybindingsForAction: (action: ActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key] === action + ); + }, + + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev); + }, + }), + { + name: "opencut-keybindings", + version: 1, + } + ) +); + +// Utility functions +function generateKeybindingString(ev: KeyboardEvent): string | 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): string | 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); + } + + // Check for special keys + if (key === "tab") return "tab"; + if (key === "home") return "home"; + if (key === "end") return "end"; + if (key === "delete") return "delete"; + if (key === "backspace") return "backspace"; + + // Check letter keys + const isLetter = key.length === 1 && key >= "a" && key <= "z"; + if (isLetter) return key; + + // Check if number keys + const isDigit = key.length === 1 && key >= "0" && key <= "9"; + if (isDigit) return 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): string | 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; +} From 33531fb3bf2628d5c5c79d43e0e37b543c9384e1 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:37:12 +0600 Subject: [PATCH 08/12] feat: implement keybindings listener and disabler composables in use-keybindings hook --- apps/web/src/constants/keybindings.ts | 204 -------------------------- apps/web/src/hooks/use-keybindings.ts | 76 ++++++++++ 2 files changed, 76 insertions(+), 204 deletions(-) delete mode 100644 apps/web/src/constants/keybindings.ts create mode 100644 apps/web/src/hooks/use-keybindings.ts diff --git a/apps/web/src/constants/keybindings.ts b/apps/web/src/constants/keybindings.ts deleted file mode 100644 index a5cdea1c..00000000 --- a/apps/web/src/constants/keybindings.ts +++ /dev/null @@ -1,204 +0,0 @@ -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" | "home" - | "end" | "delete" | "backspace"; -/* 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", - j: "seek-backward", - k: "toggle-play", - l: "seek-forward", - left: "frame-step-backward", - right: "frame-step-forward", - "shift-left": "jump-backward", - "shift-right": "jump-forward", - home: "goto-start", - end: "goto-end", - s: "split-element", - n: "toggle-snapping", - "ctrl-a": "select-all", - "ctrl-d": "duplicate-selected", - "ctrl-z": "undo", - "ctrl-shift-z": "redo", - "ctrl-y": "redo", - delete: "delete-selected", - backspace: "delete-selected", -}; - -/** - * 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 for special keys - if (key === "home") return "home"; - if (key === "end") return "end"; - if (key === "delete") return "delete"; - if (key === "backspace") return "backspace"; - - // 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/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts new file mode 100644 index 00000000..6c7c0d06 --- /dev/null +++ b/apps/web/src/hooks/use-keybindings.ts @@ -0,0 +1,76 @@ +import { useEffect } from "react"; +import { ActionWithOptionalArgs, invokeAction } from "../constants/actions"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; + +/** + * 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; + +/** + * 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() { + const { keybindings, getKeybindingString } = useKeybindingsStore(); + + useEffect(() => { + const handleKeyDown = (ev: KeyboardEvent) => { + // Do not check keybinds if the mode is disabled + if (!keybindingsEnabled) return; + + const binding = getKeybindingString(ev); + if (!binding) return; + + const boundAction = keybindings[binding]; + if (!boundAction) return; + + ev.preventDefault(); + + // Handle actions with default arguments + let actionArgs: any = undefined; + + if (boundAction === "seek-forward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "seek-backward") { + actionArgs = { seconds: 1 }; + } else if (boundAction === "jump-forward") { + actionArgs = { seconds: 5 }; + } else if (boundAction === "jump-backward") { + actionArgs = { seconds: 5 }; + } + + invokeAction(boundAction, actionArgs, "keypress"); + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [keybindings, getKeybindingString]); +} + +/** + * This composable allows for the UI component to be disabled if the component in question is mounted + */ +export function useKeybindingDisabler() { + const disableKeybindings = () => { + keybindingsEnabled = false; + }; + + const enableKeybindings = () => { + keybindingsEnabled = true; + }; + + return { + disableKeybindings, + enableKeybindings, + }; +} + +// Export the bindings for backward compatibility +export const bindings = {}; From cdae10ca0aeaf23d49591cb81fe9dac7b83ff642 Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:38:32 +0600 Subject: [PATCH 09/12] feat: refactor keybinding management and add keybinding editor component --- apps/web/src/components/editor-provider.tsx | 2 +- apps/web/src/components/keybinding-editor.tsx | 414 ++++++++++++++++++ .../components/keyboard-shortcuts-help.tsx | 36 +- .../web/src/hooks/use-keybinding-conflicts.ts | 59 +++ .../src/hooks/use-keyboard-shortcuts-help.ts | 8 +- 5 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/keybinding-editor.tsx create mode 100644 apps/web/src/hooks/use-keybinding-conflicts.ts diff --git a/apps/web/src/components/editor-provider.tsx b/apps/web/src/components/editor-provider.tsx index ae1bb1a5..25d040db 100644 --- a/apps/web/src/components/editor-provider.tsx +++ b/apps/web/src/components/editor-provider.tsx @@ -6,7 +6,7 @@ import { useEditorStore } from "@/stores/editor-store"; import { useKeybindingsListener, useKeybindingDisabler, -} from "@/constants/keybindings"; +} from "@/hooks/use-keybindings"; import { useEditorActions } from "@/hooks/use-editor-actions"; interface EditorProviderProps { diff --git a/apps/web/src/components/keybinding-editor.tsx b/apps/web/src/components/keybinding-editor.tsx new file mode 100644 index 00000000..8735c504 --- /dev/null +++ b/apps/web/src/components/keybinding-editor.tsx @@ -0,0 +1,414 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { Action } from "@/constants/actions"; +import { KeyboardShortcut } from "@/hooks/use-keyboard-shortcuts-help"; +import { + Settings, + RotateCcw, + Download, + Upload, + X, + Check, + AlertTriangle, +} from "lucide-react"; +import { toast } from "sonner"; + +interface KeybindingEditorProps { + shortcuts: KeyboardShortcut[]; + onClose: () => void; +} + +interface KeyRecorderProps { + value: string; + onValueChange: (value: string) => void; + onCancel: () => void; +} + +const KeyRecorder = ({ value, onValueChange, onCancel }: KeyRecorderProps) => { + const [isRecording, setIsRecording] = useState(false); + const [recordedKey, setRecordedKey] = useState(""); + const { getKeybindingString } = useKeybindingsStore(); + + useEffect(() => { + if (!isRecording) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + + const keyString = getKeybindingString(e); + if (keyString) { + setRecordedKey(keyString); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isRecording, getKeybindingString]); + + const handleStartRecording = () => { + setIsRecording(true); + setRecordedKey(""); + }; + + const handleConfirm = () => { + onValueChange(recordedKey); + setIsRecording(false); + setRecordedKey(""); + }; + + const handleCancel = () => { + setIsRecording(false); + setRecordedKey(""); + onCancel(); + }; + + const displayKey = recordedKey || value; + + return ( +
+ + {isRecording ? ( +
+ + +
+ ) : ( + + )} +
+ ); +}; + +export const KeybindingEditor = ({ + shortcuts, + onClose, +}: KeybindingEditorProps) => { + const { + keybindings, + updateKeybinding, + removeKeybinding, + resetToDefaults, + isCustomized, + validateKeybinding, + getKeybindingsForAction, + exportKeybindings, + importKeybindings, + } = useKeybindingsStore(); + + const [editingShortcut, setEditingShortcut] = useState(null); + const [newKeyBinding, setNewKeyBinding] = useState(""); + const [showResetDialog, setShowResetDialog] = useState(false); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); + + const categories = [ + "all", + ...Array.from(new Set(shortcuts.map((s) => s.category))), + ]; + + const filteredShortcuts = shortcuts.filter((shortcut) => { + const matchesSearch = + shortcut.description.toLowerCase().includes(searchTerm.toLowerCase()) || + shortcut.keys.some((key) => + key.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const matchesCategory = + selectedCategory === "all" || shortcut.category === selectedCategory; + return matchesSearch && matchesCategory; + }); + + const handleEditShortcut = (shortcut: KeyboardShortcut) => { + setEditingShortcut(shortcut.id); + setNewKeyBinding(shortcut.keys[0] || ""); + }; + + const handleSaveShortcut = () => { + if (!editingShortcut || !newKeyBinding) return; + + const shortcut = shortcuts.find((s) => s.id === editingShortcut); + if (!shortcut) return; + + // Validate the new keybinding + const conflict = validateKeybinding(newKeyBinding, shortcut.action); + if (conflict) { + toast.error( + `Key "${newKeyBinding}" is already bound to "${conflict.existingAction}"` + ); + return; + } + + // Remove old keybindings for this action + const oldKeys = getKeybindingsForAction(shortcut.action); + oldKeys.forEach((key) => removeKeybinding(key)); + + // Add new keybinding + updateKeybinding(newKeyBinding, shortcut.action); + + setEditingShortcut(null); + setNewKeyBinding(""); + toast.success("Keybinding updated successfully"); + }; + + const handleCancelEdit = () => { + setEditingShortcut(null); + setNewKeyBinding(""); + }; + + const handleRemoveShortcut = (shortcut: KeyboardShortcut) => { + const keys = getKeybindingsForAction(shortcut.action); + keys.forEach((key) => removeKeybinding(key)); + toast.success("Keybinding removed"); + }; + + const handleResetToDefaults = () => { + resetToDefaults(); + setShowResetDialog(false); + toast.success("Keybindings reset to defaults"); + }; + + const handleExportKeybindings = () => { + const config = exportKeybindings(); + const blob = new Blob([JSON.stringify(config, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "opencut-keybindings.json"; + a.click(); + URL.revokeObjectURL(url); + toast.success("Keybindings exported"); + }; + + const handleImportKeybindings = ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const config = JSON.parse(e.target?.result as string); + importKeybindings(config); + toast.success("Keybindings imported successfully"); + } catch (error) { + toast.error("Failed to import keybindings file"); + } + }; + reader.readAsText(file); + }; + + return ( +
+
+
+ +

+ Customize Keyboard Shortcuts +

+ {isCustomized && ( + + Modified + + )} +
+
+ + + + + +
+
+ +
+ setSearchTerm(e.target.value)} + className="flex-1" + /> + + + {categories.map((category) => ( + + {category === "all" ? "All" : category} + + ))} + + +
+ +
+ {filteredShortcuts.map((shortcut) => ( + + +
+
+
+
+

{shortcut.description}

+

+ {shortcut.category} +

+
+
+
+
+
+ {editingShortcut === shortcut.id ? ( + + ) : ( +
+ {shortcut.keys.map((key, index) => ( + + {key} + + ))} + {shortcut.keys.length === 0 && ( + + No binding + + )} +
+ )} +
+
+ {editingShortcut === shortcut.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+
+ ))} +
+ + + + + + + Reset Keyboard Shortcuts? + + + This will reset all keyboard shortcuts to their default values. + Any custom keybindings will be lost. + + + + Cancel + + Reset to Defaults + + + + +
+ ); +}; diff --git a/apps/web/src/components/keyboard-shortcuts-help.tsx b/apps/web/src/components/keyboard-shortcuts-help.tsx index c908b9ce..c40e5eb4 100644 --- a/apps/web/src/components/keyboard-shortcuts-help.tsx +++ b/apps/web/src/components/keyboard-shortcuts-help.tsx @@ -11,11 +11,12 @@ import { DialogTrigger, } from "./ui/dialog"; import { Badge } from "./ui/badge"; -import { Keyboard } from "lucide-react"; +import { Keyboard, Settings } from "lucide-react"; import { useKeyboardShortcutsHelp, KeyboardShortcut, } from "@/hooks/use-keyboard-shortcuts-help"; +import { KeybindingEditor } from "./keybinding-editor"; const KeyBadge = ({ keyName }: { keyName: string }) => { // Replace common key names with symbols or friendly names @@ -88,12 +89,32 @@ const ShortcutItem = ({ shortcut }: { shortcut: KeyboardShortcut }) => { export const KeyboardShortcutsHelp = () => { const [open, setOpen] = useState(false); + const [showEditor, setShowEditor] = useState(false); // Get shortcuts from centralized hook const { shortcuts } = useKeyboardShortcutsHelp(); const categories = Array.from(new Set(shortcuts.map((s) => s.category))); + if (showEditor) { + return ( + + + + + + setShowEditor(false)} + /> + + + ); + } + return ( @@ -102,7 +123,7 @@ export const KeyboardShortcutsHelp = () => { Shortcuts - + @@ -114,6 +135,17 @@ export const KeyboardShortcutsHelp = () => { +
+ +
+
{categories.map((category) => (
diff --git a/apps/web/src/hooks/use-keybinding-conflicts.ts b/apps/web/src/hooks/use-keybinding-conflicts.ts new file mode 100644 index 00000000..cc5dd340 --- /dev/null +++ b/apps/web/src/hooks/use-keybinding-conflicts.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useMemo } from "react"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { ActionWithOptionalArgs } from "@/constants/actions"; + +export interface KeybindingConflictInfo { + key: string; + actions: ActionWithOptionalArgs[]; + isConflict: boolean; +} + +export const useKeybindingConflicts = () => { + const { keybindings } = useKeybindingsStore(); + + const conflicts = useMemo(() => { + const keyToActions: Record = {}; + const conflictList: KeybindingConflictInfo[] = []; + + // Group actions by key + Object.entries(keybindings).forEach(([key, action]) => { + if (!keyToActions[key]) { + keyToActions[key] = []; + } + keyToActions[key].push(action); + }); + + // Find conflicts + Object.entries(keyToActions).forEach(([key, actions]) => { + const uniqueActions = [...new Set(actions)]; + conflictList.push({ + key, + actions: uniqueActions, + isConflict: uniqueActions.length > 1, + }); + }); + + return conflictList.filter((item) => item.isConflict); + }, [keybindings]); + + const hasConflicts = conflicts.length > 0; + + const getConflictsForKey = (key: string): KeybindingConflictInfo | null => { + return conflicts.find((conflict) => conflict.key === key) || null; + }; + + const getConflictsForAction = ( + action: ActionWithOptionalArgs + ): KeybindingConflictInfo[] => { + return conflicts.filter((conflict) => conflict.actions.includes(action)); + }; + + return { + conflicts, + hasConflicts, + getConflictsForKey, + getConflictsForAction, + }; +}; diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index cac68524..bd8beffc 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { bindings } from "@/constants/keybindings"; +import { useKeybindingsStore } from "@/stores/keybindings-store"; import { Action } from "@/constants/actions"; export interface KeyboardShortcut { @@ -83,13 +83,15 @@ const formatKey = (key: string): string => { }; export const useKeyboardShortcutsHelp = () => { + const { keybindings } = useKeybindingsStore(); + const shortcuts = useMemo(() => { const result: KeyboardShortcut[] = []; // Group keybindings by action const actionToKeys: Record = {} as any; - Object.entries(bindings).forEach(([key, action]) => { + Object.entries(keybindings).forEach(([key, action]) => { if (action) { if (!actionToKeys[action]) { actionToKeys[action] = []; @@ -113,7 +115,7 @@ export const useKeyboardShortcutsHelp = () => { }); return result; - }, []); + }, [keybindings]); return { shortcuts, From 8e4661951d214f917357caef9d2895f2a4f7eb1e Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 08:05:02 +0600 Subject: [PATCH 10/12] feat: enhance keybinding management by integrating keybindingsEnabled state and updating related hooks --- apps/web/src/hooks/use-keybindings.ts | 22 +++++----------------- apps/web/src/stores/keybindings-store.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/web/src/hooks/use-keybindings.ts b/apps/web/src/hooks/use-keybindings.ts index 6c7c0d06..7887f3f1 100644 --- a/apps/web/src/hooks/use-keybindings.ts +++ b/apps/web/src/hooks/use-keybindings.ts @@ -1,21 +1,15 @@ import { useEffect } from "react"; -import { ActionWithOptionalArgs, invokeAction } from "../constants/actions"; +import { invokeAction } from "../constants/actions"; import { useKeybindingsStore } from "@/stores/keybindings-store"; -/** - * 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; - /** * 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() { - const { keybindings, getKeybindingString } = useKeybindingsStore(); + const { keybindings, getKeybindingString, keybindingsEnabled } = + useKeybindingsStore(); useEffect(() => { const handleKeyDown = (ev: KeyboardEvent) => { @@ -51,20 +45,14 @@ export function useKeybindingsListener() { return () => { document.removeEventListener("keydown", handleKeyDown); }; - }, [keybindings, getKeybindingString]); + }, [keybindings, getKeybindingString, keybindingsEnabled]); } /** * This composable allows for the UI component to be disabled if the component in question is mounted */ export function useKeybindingDisabler() { - const disableKeybindings = () => { - keybindingsEnabled = false; - }; - - const enableKeybindings = () => { - keybindingsEnabled = true; - }; + const { disableKeybindings, enableKeybindings } = useKeybindingsStore(); return { disableKeybindings, diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index ddfb270b..6a72423f 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -43,6 +43,7 @@ export interface KeybindingConflict { interface KeybindingsState { keybindings: KeybindingConfig; isCustomized: boolean; + keybindingsEnabled: boolean; // Actions updateKeybinding: (key: string, action: ActionWithOptionalArgs) => void; @@ -50,6 +51,8 @@ interface KeybindingsState { resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; exportKeybindings: () => KeybindingConfig; + enableKeybindings: () => void; + disableKeybindings: () => void; // Validation validateKeybinding: ( @@ -67,6 +70,7 @@ export const useKeybindingsStore = create()( (set, get) => ({ keybindings: { ...defaultKeybindings }, isCustomized: false, + keybindingsEnabled: true, updateKeybinding: (key: string, action: ActionWithOptionalArgs) => { set((state) => { @@ -99,6 +103,14 @@ export const useKeybindingsStore = create()( }); }, + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, + + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, + importKeybindings: (config: KeybindingConfig) => { set({ keybindings: { ...config }, From c3caefc001e1ea6a7e5626acb1fe1054fa00a56a Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 08:30:42 +0600 Subject: [PATCH 11/12] 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(-) 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"; From 23001f8442e60999e34c0d6c348fecf0867b6cad Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 15:19:46 +0600 Subject: [PATCH 12/12] feat: define keybinding types and refactor keybinding configuration --- apps/web/src/stores/keybindings-store.ts | 58 ++++++++++++------------ apps/web/src/types/keybinding.ts | 35 ++++++++++++++ 2 files changed, 63 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/types/keybinding.ts diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 6d9f5d54..60c71afd 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -4,12 +4,7 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { ActionWithOptionalArgs } from "@/constants/actions"; import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils"; - -export type ShortcutKey = string; - -export interface KeybindingConfig { - [key: ShortcutKey]: ActionWithOptionalArgs; -} +import { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; // Default keybindings configuration export const defaultKeybindings: KeybindingConfig = { @@ -19,23 +14,23 @@ export const defaultKeybindings: KeybindingConfig = { l: "seek-forward", left: "frame-step-backward", right: "frame-step-forward", - "shift-left": "jump-backward", - "shift-right": "jump-forward", + "shift+left": "jump-backward", + "shift+right": "jump-forward", home: "goto-start", end: "goto-end", s: "split-element", n: "toggle-snapping", - "ctrl-a": "select-all", - "ctrl-d": "duplicate-selected", - "ctrl-z": "undo", - "ctrl-shift-z": "redo", - "ctrl-y": "redo", + "ctrl+a": "select-all", + "ctrl+d": "duplicate-selected", + "ctrl+z": "undo", + "ctrl+shift+z": "redo", + "ctrl+y": "redo", delete: "delete-selected", backspace: "delete-selected", }; export interface KeybindingConflict { - key: string; + key: ShortcutKey; existingAction: ActionWithOptionalArgs; newAction: ActionWithOptionalArgs; } @@ -46,8 +41,8 @@ interface KeybindingsState { keybindingsEnabled: boolean; // Actions - updateKeybinding: (key: string, action: ActionWithOptionalArgs) => void; - removeKeybinding: (key: string) => void; + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; exportKeybindings: () => KeybindingConfig; @@ -56,13 +51,13 @@ interface KeybindingsState { // Validation validateKeybinding: ( - key: string, + key: ShortcutKey, action: ActionWithOptionalArgs ) => KeybindingConflict | null; - getKeybindingsForAction: (action: ActionWithOptionalArgs) => string[]; + getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; // Utility - getKeybindingString: (ev: KeyboardEvent) => string | null; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } export const useKeybindingsStore = create()( @@ -72,7 +67,7 @@ export const useKeybindingsStore = create()( isCustomized: false, keybindingsEnabled: true, - updateKeybinding: (key: string, action: ActionWithOptionalArgs) => { + updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { set((state) => { const newKeybindings = { ...state.keybindings }; newKeybindings[key] = action; @@ -84,7 +79,7 @@ export const useKeybindingsStore = create()( }); }, - removeKeybinding: (key: string) => { + removeKeybinding: (key: ShortcutKey) => { set((state) => { const newKeybindings = { ...state.keybindings }; delete newKeybindings[key]; @@ -129,7 +124,10 @@ export const useKeybindingsStore = create()( return get().keybindings; }, - validateKeybinding: (key: string, action: ActionWithOptionalArgs) => { + validateKeybinding: ( + key: ShortcutKey, + action: ActionWithOptionalArgs + ) => { const { keybindings } = get(); const existingAction = keybindings[key]; @@ -147,12 +145,12 @@ export const useKeybindingsStore = create()( getKeybindingsForAction: (action: ActionWithOptionalArgs) => { const { keybindings } = get(); return Object.keys(keybindings).filter( - (key) => keybindings[key] === action - ); + (key) => keybindings[key as ShortcutKey] === action + ) as ShortcutKey[]; }, getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev); + return generateKeybindingString(ev) as ShortcutKey | null; }, }), { @@ -163,7 +161,7 @@ export const useKeybindingsStore = create()( ); // Utility functions -function generateKeybindingString(ev: KeyboardEvent): string | null { +function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { const target = ev.target; // We may or may not have a modifier key @@ -184,14 +182,14 @@ function generateKeybindingString(ev: KeyboardEvent): string | null { return null; } - return `${modifierKey}-${key}`; + return `${modifierKey}+${key}` as ShortcutKey; } // 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}`; + return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { @@ -233,11 +231,11 @@ function getActiveModifier(ev: KeyboardEvent): string | null { shift: ev.shiftKey, }; - // active modifier: ctrl | alt | ctrl-alt | ctrl-shift | ctrl-alt-shift | alt-shift + // 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("-"); + .join("+"); return activeModifier === "" ? null : activeModifier; } diff --git a/apps/web/src/types/keybinding.ts b/apps/web/src/types/keybinding.ts new file mode 100644 index 00000000..d97c88da --- /dev/null +++ b/apps/web/src/types/keybinding.ts @@ -0,0 +1,35 @@ +import { ActionWithOptionalArgs } from "@/constants/actions"; + +/** + * 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!) + */ +export type ModifierKeys = + | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift"; + +/* eslint-disable prettier/prettier */ +// prettier-ignore +export 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" + | "escape" | "esc" | "backspace" | "delete" | "home" | "end" +/* eslint-enable */ + +export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`; +// Singular keybindings (these will be disabled when an input-ish area has been focused) +export type SingleCharacterShortcutKey = `${Key}`; + +export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey; + +export type KeybindingConfig = { + [key in ShortcutKey]?: ActionWithOptionalArgs; +};