(feat): add hotkeys
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

This commit is contained in:
Alois 2026-08-02 01:10:05 +02:00
commit 85633a1c81
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
27 changed files with 1014 additions and 33 deletions

View file

@ -0,0 +1,24 @@
{
"name": "@tensamin/hotkeys",
"private": true,
"version": "0.0.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"format": "pnpm exec prettier --write .",
"lint": "eslint src",
"test": "vitest run --passWithNoTests",
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@tanstack/react-hotkeys": "^0.10.0",
"@tensamin/storage": "workspace:*",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"vite": "^8.0.10"
}
}

View file

@ -0,0 +1,252 @@
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]);
}

View file

@ -0,0 +1,14 @@
export { HotkeysProvider, useHotkey, useHotkeysContext } from "./context";
export {
defineHotkey,
getHotkeyDefinitions,
normalizeHotkeyOverrides,
toElectronAccelerator,
useHotkeyDefinitions,
type HotkeyDefinition,
} from "./registry";
export {
formatForDisplay,
useHotkeyRecorder,
type Hotkey,
} from "@tanstack/react-hotkeys";

View file

@ -0,0 +1,94 @@
import { useSyncExternalStore } from "react";
import { validateHotkey, type Hotkey } from "@tanstack/react-hotkeys";
export type HotkeyDefinition = Readonly<{
id: string;
name: string;
description?: string;
category: string;
defaultBinding: Hotkey;
global?: boolean;
}>;
const definitions = new Map<string, HotkeyDefinition>();
const listeners = new Set<() => void>();
let snapshot: HotkeyDefinition[] = [];
export function defineHotkey(definition: HotkeyDefinition) {
const existing = definitions.get(definition.id);
if (existing) return existing;
definitions.set(definition.id, Object.freeze({ ...definition }));
snapshot = [...definitions.values()];
listeners.forEach((listener) => listener());
return definitions.get(definition.id)!;
}
export function getHotkeyDefinitions() {
return snapshot;
}
export function useHotkeyDefinitions() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
getHotkeyDefinitions,
getHotkeyDefinitions,
);
}
export function normalizeHotkeyOverrides(value: unknown) {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return Object.fromEntries(
Object.entries(value).filter(
([id, binding]) =>
id.length > 0 &&
id.length <= 128 &&
(binding === null ||
(typeof binding === "string" &&
binding.length > 0 &&
binding.length <= 128 &&
validateHotkey(binding).valid)),
),
) as Record<string, Hotkey | null>;
}
export function toElectronAccelerator(hotkey: Hotkey) {
const keyAliases: Record<string, string> = {
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
ArrowUp: "Up",
" ": "Space",
};
const modifierAliases: Record<string, string> = {
Alt: "Alt",
Control: "Control",
Ctrl: "Control",
Meta: "Command",
Mod: "CommandOrControl",
Shift: "Shift",
};
const parts = hotkey.split("+");
if (parts.length === 0) return null;
const key = parts.at(-1)!;
const modifiers = parts.slice(0, -1).map((part) => modifierAliases[part]);
if (modifiers.some((part) => !part)) return null;
const acceleratorKey = keyAliases[key] ?? key;
if (
!/^[A-Za-z0-9]$/.test(acceleratorKey) &&
!keyAliases[key] &&
!/^(Backspace|Delete|End|Enter|Escape|F([1-9]|1[0-9]|2[0-4])|Home|PageDown|PageUp|Space|Tab)$/.test(
acceleratorKey,
)
) {
return null;
}
return [...modifiers, acceleratorKey].join("+");
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}