From 4137747abacd74a1e75702c93ab403e7bd2b218b Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 07:35:31 +0600 Subject: 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 (limited to 'apps/web/src/stores') 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; +} -- cgit v1.3.1 From 8e4661951d214f917357caef9d2895f2a4f7eb1e Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 08:05:02 +0600 Subject: 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(-) (limited to 'apps/web/src/stores') 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 }, -- 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/stores') 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 From 23001f8442e60999e34c0d6c348fecf0867b6cad Mon Sep 17 00:00:00 2001 From: Anwarul Islam Date: Fri, 18 Jul 2025 15:19:46 +0600 Subject: 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 (limited to 'apps/web/src/stores') 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; +}; -- cgit v1.3.1