import { HotkeysProvider as TanStackHotkeysProvider, useHotkeys as useTanStackHotkeys, type Hotkey, type UseHotkeyOptions, } from "@tanstack/react-hotkeys"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useStorage } from "@tensamin/storage/context"; import { getHotkeyDefinitions, normalizeHotkeyOverrides, toElectronAccelerator, type HotkeyDefinition, } from "./registry"; type GlobalRegistrationStatus = "registered" | "unavailable"; type HotkeysContextValue = { overrides: Record; bindingFor: (definition: HotkeyDefinition) => Hotkey | null; setBinding: (definition: HotkeyDefinition, binding: Hotkey | null) => void; resetBinding: (definition: HotkeyDefinition) => void; resetAll: () => void; globalStatuses: Record; setRecording: (recording: boolean) => void; registerGlobalHandler: ( definition: HotkeyDefinition, handler: () => void, ) => () => void; }; const HotkeysContext = createContext( undefined, ); export function HotkeysProvider({ children }: { children: ReactNode }) { const { load, save } = useStorage(); const [overrides, setOverrides] = useState>({}); const [handlersRevision, setHandlersRevision] = useState(0); const [globalStatuses, setGlobalStatuses] = useState< Record >({}); const handlers = useRef(new Map void>>()); const overridesRef = useRef>({}); useEffect(() => { let active = true; void load("hotkey_overrides") .then((stored) => { if (!active) return; const next = normalizeHotkeyOverrides(stored); overridesRef.current = next; setOverrides(next); }) .catch((error: unknown) => { console.error("Failed to load hotkey settings", error); }); return () => { active = false; }; }, [load]); const persist = useCallback( (next: Record) => { overridesRef.current = next; setOverrides(next); void save("hotkey_overrides", next).catch((error: unknown) => { console.error("Failed to save hotkey settings", error); }); }, [save], ); const bindingFor = useCallback( (definition: HotkeyDefinition) => Object.hasOwn(overrides, definition.id) ? (overrides[definition.id] ?? null) : definition.defaultBinding, [overrides], ); const setBinding = useCallback( (definition: HotkeyDefinition, binding: Hotkey | null) => { const next = { ...overridesRef.current }; if (binding === definition.defaultBinding) delete next[definition.id]; else next[definition.id] = binding; persist(next); }, [persist], ); const resetBinding = useCallback( (definition: HotkeyDefinition) => { const next = { ...overridesRef.current }; delete next[definition.id]; persist(next); }, [persist], ); const resetAll = useCallback(() => persist({}), [persist]); const registerGlobalHandler = useCallback( (definition: HotkeyDefinition, handler: () => void) => { const current = handlers.current.get(definition.id) ?? new Set(); current.add(handler); handlers.current.set(definition.id, current); setHandlersRevision((revision) => revision + 1); return () => { current.delete(handler); if (current.size === 0) handlers.current.delete(definition.id); setHandlersRevision((revision) => revision + 1); }; }, [], ); useEffect(() => { return window.tensaminDesktop?.hotkeys?.onTriggered?.((id) => { handlers.current.get(id)?.forEach((handler) => handler()); }); }, []); useEffect(() => { const desktopHotkeys = window.tensaminDesktop?.hotkeys; if (!desktopHotkeys?.setBindings) return; const unsupported: string[] = []; const registrations = getHotkeyDefinitions().flatMap((definition) => { if (!definition.global || !handlers.current.has(definition.id)) return []; const binding = bindingFor(definition); const accelerator = binding && toElectronAccelerator(binding); if (binding && !accelerator) unsupported.push(definition.id); return accelerator ? [{ id: definition.id, accelerator }] : []; }); let active = true; void desktopHotkeys .setBindings(registrations) .then((statuses) => { if (!active) return; setGlobalStatuses( Object.fromEntries( [ ...unsupported.map((id) => [id, false] as const), ...Object.entries(statuses), ].map(([id, registered]) => [ id, registered ? "registered" : "unavailable", ]), ), ); }) .catch((error: unknown) => { console.error("Failed to register global hotkeys", error); }); return () => { active = false; }; }, [bindingFor, handlersRevision]); const setRecording = useCallback((recording: boolean) => { void window.tensaminDesktop?.hotkeys ?.setSuspended?.(recording) .catch((error: unknown) => { console.error("Failed to suspend global hotkeys", error); }); }, []); const value = useMemo( () => ({ overrides, bindingFor, setBinding, resetBinding, resetAll, globalStatuses, setRecording, registerGlobalHandler, }), [ bindingFor, globalStatuses, overrides, registerGlobalHandler, resetAll, resetBinding, setBinding, setRecording, ], ); return ( {children} ); } export function useHotkeysContext() { const value = useContext(HotkeysContext); if (!value) throw new Error("useHotkeysContext must be used within HotkeysProvider"); return value; } export function useHotkey( definition: HotkeyDefinition, callback: () => void, options: UseHotkeyOptions = {}, ) { const { bindingFor, registerGlobalHandler } = useHotkeysContext(); const callbackRef = useRef(callback); useEffect(() => { callbackRef.current = callback; }, [callback]); const binding = bindingFor(definition); const handledByElectron = Boolean( definition.global && window.tensaminDesktop?.hotkeys, ); useTanStackHotkeys( binding && !handledByElectron && options.enabled !== false ? [ { hotkey: binding, callback: () => callbackRef.current(), options: { ...options, meta: { name: definition.name, description: definition.description, }, }, }, ] : [], ); useEffect(() => { if (!definition.global || options.enabled === false || !binding) return; return registerGlobalHandler(definition, () => callbackRef.current()); }, [binding, definition, options.enabled, registerGlobalHandler]); }