client/packages/hotkeys/src/context.tsx
Alois 85633a1c81
Some checks failed
/ build-web (push) Successful in 7m53s
/ build-desktop (linux) (push) Successful in 12m24s
/ release (push) Has been cancelled
/ build-mobile (push) Has been cancelled
(feat): add hotkeys
2026-08-02 01:10:05 +02:00

252 lines
7.1 KiB
TypeScript

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<string, Hotkey | null>;
bindingFor: (definition: HotkeyDefinition) => Hotkey | null;
setBinding: (definition: HotkeyDefinition, binding: Hotkey | null) => void;
resetBinding: (definition: HotkeyDefinition) => void;
resetAll: () => void;
globalStatuses: Record<string, GlobalRegistrationStatus>;
setRecording: (recording: boolean) => void;
registerGlobalHandler: (
definition: HotkeyDefinition,
handler: () => void,
) => () => void;
};
const HotkeysContext = createContext<HotkeysContextValue | undefined>(
undefined,
);
export function HotkeysProvider({ children }: { children: ReactNode }) {
const { load, save } = useStorage();
const [overrides, setOverrides] = useState<Record<string, Hotkey | null>>({});
const [handlersRevision, setHandlersRevision] = useState(0);
const [globalStatuses, setGlobalStatuses] = useState<
Record<string, GlobalRegistrationStatus>
>({});
const handlers = useRef(new Map<string, Set<() => void>>());
const overridesRef = useRef<Record<string, Hotkey | null>>({});
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<string, Hotkey | null>) => {
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<HotkeysContextValue>(
() => ({
overrides,
bindingFor,
setBinding,
resetBinding,
resetAll,
globalStatuses,
setRecording,
registerGlobalHandler,
}),
[
bindingFor,
globalStatuses,
overrides,
registerGlobalHandler,
resetAll,
resetBinding,
setBinding,
setRecording,
],
);
return (
<TanStackHotkeysProvider>
<HotkeysContext value={value}>{children}</HotkeysContext>
</TanStackHotkeysProvider>
);
}
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]);
}