From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/stores/keybindings-store.ts | 475 +++++++++++++++---------------- 1 file changed, 233 insertions(+), 242 deletions(-) (limited to '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 index febcaa30..5ff48ffc 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -2,264 +2,255 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; -import { ActionWithOptionalArgs } from "@/constants/actions"; -import { isAppleDevice, isDOMElement, isTypableElement } from "@/lib/utils"; -import { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; - -// 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", - enter: "goto-start", - end: "goto-end", - s: "split-element", - n: "toggle-snapping", - "ctrl+a": "select-all", - "ctrl+d": "duplicate-selected", - "ctrl+c": "copy-selected", - "ctrl+v": "paste-selected", - "ctrl+z": "undo", - "ctrl+shift+z": "redo", - "ctrl+y": "redo", - delete: "delete-selected", - backspace: "delete-selected", -}; +import type { TActionWithOptionalArgs } from "@/lib/actions"; +import { getDefaultShortcuts } from "@/lib/actions"; +import { isTypableDOMElement } from "@/utils/browser"; +import { isAppleDevice } from "@/utils/platform"; +import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; +import { + runMigrations, + CURRENT_VERSION, +} from "./keybindings/migrations"; + +export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); export interface KeybindingConflict { - key: ShortcutKey; - existingAction: ActionWithOptionalArgs; - newAction: ActionWithOptionalArgs; + key: ShortcutKey; + existingAction: TActionWithOptionalArgs; + newAction: TActionWithOptionalArgs; } interface KeybindingsState { - keybindings: KeybindingConfig; - isCustomized: boolean; - keybindingsEnabled: boolean; - isRecording: boolean; - - // Actions - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => void; - removeKeybinding: (key: ShortcutKey) => void; - resetToDefaults: () => void; - importKeybindings: (config: KeybindingConfig) => void; - exportKeybindings: () => KeybindingConfig; - enableKeybindings: () => void; - disableKeybindings: () => void; - setIsRecording: (isRecording: boolean) => void; - - // Validation - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => KeybindingConflict | null; - getKeybindingsForAction: (action: ActionWithOptionalArgs) => ShortcutKey[]; - - // Utility - getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; + keybindings: KeybindingConfig; + isCustomized: boolean; + keybindingsEnabled: boolean; + isRecording: boolean; + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => void; + removeKeybinding: (key: ShortcutKey) => void; + resetToDefaults: () => void; + importKeybindings: (config: KeybindingConfig) => void; + exportKeybindings: () => KeybindingConfig; + enableKeybindings: () => void; + disableKeybindings: () => void; + setIsRecording: (isRecording: boolean) => void; + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => KeybindingConflict | null; + getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; + getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; +} + +function isDOMElement(el: EventTarget | null): el is HTMLElement { + return !!el && (el instanceof Element || el instanceof HTMLElement); } export const useKeybindingsStore = create()( - persist( - (set, get) => ({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - keybindingsEnabled: true, - isRecording: false, - - updateKeybinding: (key: ShortcutKey, action: ActionWithOptionalArgs) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - newKeybindings[key] = action; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - removeKeybinding: (key: ShortcutKey) => { - set((state) => { - const newKeybindings = { ...state.keybindings }; - delete newKeybindings[key]; - - return { - keybindings: newKeybindings, - isCustomized: true, - }; - }); - }, - - resetToDefaults: () => { - set({ - keybindings: { ...defaultKeybindings }, - isCustomized: false, - }); - }, - - enableKeybindings: () => { - set({ keybindingsEnabled: true }); - }, - - disableKeybindings: () => { - set({ keybindingsEnabled: false }); - }, - - 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, - }); - }, - - exportKeybindings: () => { - return get().keybindings; - }, - - validateKeybinding: ( - key: ShortcutKey, - action: ActionWithOptionalArgs - ) => { - const { keybindings } = get(); - const existingAction = keybindings[key]; - - if (existingAction && existingAction !== action) { - return { - key, - existingAction, - newAction: action, - }; - } - - return null; - }, - setIsRecording: (isRecording: boolean) => { - set({ isRecording }); - }, - - getKeybindingsForAction: (action: ActionWithOptionalArgs) => { - const { keybindings } = get(); - return Object.keys(keybindings).filter( - (key) => keybindings[key as ShortcutKey] === action - ) as ShortcutKey[]; - }, - - getKeybindingString: (ev: KeyboardEvent) => { - return generateKeybindingString(ev) as ShortcutKey | null; - }, - }), - { - name: "opencut-keybindings", - version: 2, - } - ) + persist( + (set, get) => ({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + keybindingsEnabled: true, + isRecording: false, + + updateKeybinding: (key: ShortcutKey, action: TActionWithOptionalArgs) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + newKeybindings[key] = action; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + removeKeybinding: (key: ShortcutKey) => { + set((state) => { + const newKeybindings = { ...state.keybindings }; + delete newKeybindings[key]; + + return { + keybindings: newKeybindings, + isCustomized: true, + }; + }); + }, + + resetToDefaults: () => { + set({ + keybindings: { ...defaultKeybindings }, + isCustomized: false, + }); + }, + + enableKeybindings: () => { + set({ keybindingsEnabled: true }); + }, + + disableKeybindings: () => { + set({ keybindingsEnabled: false }); + }, + + importKeybindings: (config: KeybindingConfig) => { + // Validate all keys and actions + for (const [key] 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, + }); + }, + + exportKeybindings: () => { + return get().keybindings; + }, + + validateKeybinding: ( + key: ShortcutKey, + action: TActionWithOptionalArgs, + ) => { + const { keybindings } = get(); + const existingAction = keybindings[key]; + + if (existingAction && existingAction !== action) { + return { + key, + existingAction, + newAction: action, + }; + } + + return null; + }, + setIsRecording: (isRecording: boolean) => { + set({ isRecording }); + }, + + getKeybindingsForAction: (action: TActionWithOptionalArgs) => { + const { keybindings } = get(); + return Object.keys(keybindings).filter( + (key) => keybindings[key as ShortcutKey] === action, + ) as ShortcutKey[]; + }, + + getKeybindingString: (ev: KeyboardEvent) => { + return generateKeybindingString(ev) as ShortcutKey | null; + }, + }), + { + name: "opencut-keybindings", + version: CURRENT_VERSION, + partialize: (state) => ({ + keybindings: state.keybindings, + isCustomized: state.isCustomized, + }), + migrate: (persisted, version) => + runMigrations({ state: persisted, fromVersion: version }), + }, + ), ); // Utility functions 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}` 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}` as ShortcutKey; + 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) && + isTypableDOMElement({ element: target as HTMLElement }) + ) { + return null; + } + + return `${modifierKey}+${key}` as ShortcutKey; + } + + // no modifier key here then we do not do anything while on input + if ( + isDOMElement(target) && + isTypableDOMElement({ element: target as HTMLElement }) + ) + return null; + + // single key while not input + return `${key}` as ShortcutKey; } function getPressedKey(ev: KeyboardEvent): string | null { - // Sometimes the property code is not available on the KeyboardEvent object - const key = (ev.key ?? "").toLowerCase(); - const code = ev.code ?? ""; - - if (code === "Space" || key === " " || key === "spacebar" || key === "space") - return "space"; - - // 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 - if (code.startsWith("Key")) { - const letter = code.slice(3).toLowerCase(); - if (letter.length === 1 && letter >= "a" && letter <= "z") { - return letter; - } - } - - // Check number keys using physical position for AZERTY support - if (code.startsWith("Digit")) { - const digit = code.slice(5); - if (digit.length === 1 && digit >= "0" && digit <= "9") { - return digit; - } - } - - // Fallback for other layouts - 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; + // Sometimes the property code is not available on the KeyboardEvent object + const key = (ev.key ?? "").toLowerCase(); + const code = ev.code ?? ""; + + if (code === "Space" || key === " " || key === "spacebar" || key === "space") + return "space"; + + // 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 + if (code.startsWith("Key")) { + const letter = code.slice(3).toLowerCase(); + if (letter.length === 1 && letter >= "a" && letter <= "z") { + return letter; + } + } + + // Check number keys using physical position for AZERTY support + if (code.startsWith("Digit")) { + const digit = code.slice(5); + if (digit.length === 1 && digit >= "0" && digit <= "9") { + return digit; + } + } + + // Fallback for other layouts + 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; + 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