(feat): add hotkeys
This commit is contained in:
parent
915568f739
commit
85633a1c81
27 changed files with 1014 additions and 33 deletions
24
packages/hotkeys/package.json
Normal file
24
packages/hotkeys/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
252
packages/hotkeys/src/context.tsx
Normal file
252
packages/hotkeys/src/context.tsx
Normal 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]);
|
||||
}
|
||||
14
packages/hotkeys/src/index.ts
Normal file
14
packages/hotkeys/src/index.ts
Normal 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";
|
||||
94
packages/hotkeys/src/registry.ts
Normal file
94
packages/hotkeys/src/registry.ts
Normal 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("+");
|
||||
}
|
||||
13
packages/hotkeys/tsconfig.json
Normal file
13
packages/hotkeys/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue