"use client"; import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { TActionWithOptionalArgs } from "@/actions"; import { getDefaultShortcuts } from "@/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; import type { Key, KeybindingConfig, ModifierKeys, ShortcutKey, } from "@/actions/keybinding"; import { isKey } from "@/actions/keybinding"; import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; export interface KeybindingConflict { key: ShortcutKey; existingAction: TActionWithOptionalArgs; newAction: TActionWithOptionalArgs; } interface KeybindingsState { keybindings: Map; isCustomized: boolean; overlayDepth: number; openOverlayIds: string[]; isLoadingProject: boolean; isRecording: boolean; updateKeybinding: (params: { key: ShortcutKey; action: TActionWithOptionalArgs; }) => void; removeKeybinding: (key: ShortcutKey) => void; resetToDefaults: () => void; importKeybindings: (config: KeybindingConfig) => void; exportKeybindings: () => Record; openOverlay: (overlayId: string) => void; closeOverlay: (overlayId: string) => void; setLoadingProject: (loading: boolean) => void; setIsRecording: (isRecording: boolean) => void; validateKeybinding: (params: { key: ShortcutKey; action: TActionWithOptionalArgs; }) => KeybindingConflict | null; getKeybindingsForAction: (action: TActionWithOptionalArgs) => ShortcutKey[]; getKeybindingString: (ev: KeyboardEvent) => ShortcutKey | null; } type PersistedState = { keybindings: Record; isCustomized: boolean; }; function isDOMElement(element: EventTarget | null): element is HTMLElement { return element instanceof HTMLElement; } function isPersistedState(value: unknown): value is PersistedState { if (!value || typeof value !== "object") return false; if (!("keybindings" in value) || !("isCustomized" in value)) return false; const { keybindings, isCustomized } = value; return ( typeof keybindings === "object" && keybindings !== null && typeof isCustomized === "boolean" ); } export const useKeybindingsStore = create()( persist( (set, get) => ({ keybindings: getDefaultShortcuts(), isCustomized: false, overlayDepth: 0, openOverlayIds: [], isLoadingProject: false, isRecording: false, openOverlay: (overlayId) => set((s) => { const openOverlayIds = s.openOverlayIds.includes(overlayId) ? s.openOverlayIds : [...s.openOverlayIds, overlayId]; return { openOverlayIds, overlayDepth: openOverlayIds.length, }; }), closeOverlay: (overlayId) => set((s) => { const openOverlayIds = s.openOverlayIds.filter( (id) => id !== overlayId, ); return { openOverlayIds, overlayDepth: openOverlayIds.length, }; }), setLoadingProject: (loading) => { set({ isLoadingProject: loading }); }, updateKeybinding: ({ key, action }) => { set((state) => { const next = new Map(state.keybindings); next.set(key, action); return { keybindings: next, isCustomized: true, }; }); }, removeKeybinding: (key) => { set((state) => { const next = new Map(state.keybindings); next.delete(key); return { keybindings: next, isCustomized: true, }; }); }, resetToDefaults: () => { set({ keybindings: getDefaultShortcuts(), isCustomized: false, }); }, importKeybindings: (config) => { const next = new Map(); for (const [key, action] of Object.entries(config)) { if (typeof key !== "string" || key.length === 0) { throw new Error(`Invalid key format: ${key}`); } if (action !== undefined) { // Public type's keys are `ShortcutKey`; trust the caller's typing. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion next.set(key as ShortcutKey, action); } } set({ keybindings: next, isCustomized: true, }); }, exportKeybindings: () => { return Object.fromEntries(get().keybindings); }, validateKeybinding: ({ key, action }) => { const existingAction = get().keybindings.get(key); if (existingAction && existingAction !== action) { return { key, existingAction, newAction: action, }; } return null; }, setIsRecording: (isRecording) => { set({ isRecording }); }, getKeybindingsForAction: (action) => { const result: ShortcutKey[] = []; for (const [key, mapped] of get().keybindings) { if (mapped === action) result.push(key); } return result; }, getKeybindingString: (ev) => generateKeybindingString(ev), }), { name: "opencut-keybindings", version: CURRENT_VERSION, partialize: (state): PersistedState => ({ keybindings: Object.fromEntries(state.keybindings), isCustomized: state.isCustomized, }), migrate: (persisted, version) => runMigrations({ state: persisted, fromVersion: version }), merge: (persisted, current) => { if (!isPersistedState(persisted)) return current; const entries = Object.entries(persisted.keybindings); // Persistence boundary: keys are normalized by the migration chain. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const typedEntries = entries as Array< [ShortcutKey, TActionWithOptionalArgs] >; return { ...current, keybindings: new Map(typedEntries), isCustomized: persisted.isCustomized, }; }, }, ), ); function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null { const target = ev.target; const modifierKey = getActiveModifier(ev); const key = getPressedKey(ev); if (!key) return null; if (modifierKey) { if ( modifierKey === "shift" && isDOMElement(target) && isTypableDOMElement({ element: target }) ) { return null; } return `${modifierKey}+${key}`; } if (isDOMElement(target) && isTypableDOMElement({ element: target })) { return null; } return key; } function getPressedKey(ev: KeyboardEvent): Key | null { const raw = (ev.key ?? "").toLowerCase(); const code = ev.code ?? ""; if (code === "Space" || raw === " " || raw === "spacebar" || raw === "space") return "space"; if (raw === "arrowup") return "up"; if (raw === "arrowdown") return "down"; if (raw === "arrowleft") return "left"; if (raw === "arrowright") return "right"; if (code.startsWith("Key")) { const letter = code.slice(3).toLowerCase(); if (isKey(letter)) return letter; } // Use physical key position for AZERTY and other non-QWERTY layouts. if (code.startsWith("Digit")) { const digit = code.slice(5); if (isKey(digit)) return digit; } if (isKey(raw)) return raw; return null; } function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null { const ctrl = isAppleDevice() ? ev.metaKey : ev.ctrlKey; const alt = ev.altKey; const shift = ev.shiftKey; if (ctrl && alt && shift) return "ctrl+alt+shift"; if (ctrl && alt) return "ctrl+alt"; if (ctrl && shift) return "ctrl+shift"; if (alt && shift) return "alt+shift"; if (ctrl) return "ctrl"; if (alt) return "alt"; if (shift) return "shift"; return null; }