@tanstack/hotkeys
Version:
Type-safe, framework-agnostic keyboard hotkey management for the browser
300 lines (298 loc) • 10.6 kB
JavaScript
import { detectPlatform } from "./platform.js";
import { isHotkeySequence, simplifyHotkey } from "./parse.js";
import { validateHotkey } from "./validate.js";
import { areHotkeysEqual } from "./match.js";
import { GLOBAL_SCOPE } from "./_registration.js";
import { getHotkeyManager } from "./hotkey-manager.js";
import { Store } from "@tanstack/store";
//#region src/app-hotkeys.ts
/**
* Declares every app hotkey up front, without registering any, so cheat
* sheets and settings screens can list hotkeys whose components have not
* mounted. Pass the result to `HotkeysProvider` as `appHotkeys`.
*
* @example
* ```ts
* export const appHotkeys = defineAppHotkeys({
* 'doc.save': { hotkey: 'Mod+S', meta: { name: 'Save', group: 'Document' } },
* 'nav.home': { hotkey: ['G', 'H'], meta: { name: 'Go home' }, options: { timeout: 800 } },
* 'palette.open': { hotkey: null, meta: { name: 'Command palette' } },
* })
* ```
*/
function defineAppHotkeys(definitions) {
const result = {};
for (const [id, input] of Object.entries(definitions)) {
const hotkey = input.hotkey === null ? null : simplifyHotkey(input.hotkey);
result[id] = {
"~appHotkey": true,
id,
hotkey,
defaultHotkey: hotkey,
meta: input.meta,
scope: input.scope,
options: input.options
};
}
return result;
}
/** Whether a value is an app hotkey created by `defineAppHotkeys`. */
function isAppHotkey(value) {
return typeof value === "object" && value !== null && value["~appHotkey"] === true;
}
function hotkeysEqual(left, right, platform) {
if (left === null || right === null) return left === right;
return areHotkeysEqual(left, right, platform);
}
/** Whether an app hotkey's effective hotkey differs from its default. */
function isCustomHotkey(appHotkey, platform = detectPlatform()) {
return !hotkeysEqual(appHotkey.hotkey, appHotkey.defaultHotkey, platform);
}
/** Parses a saved custom hotkey. Returns `undefined` when it is not a valid hotkey. */
function parseCustomHotkey(value) {
if (value === null) return null;
return (typeof value === "string" || Array.isArray(value) && value.every((step) => typeof step === "string")) && validateHotkey(value).valid ? simplifyHotkey(value) : void 0;
}
/** Applies custom hotkeys to declarations. Unchanged entries keep their identity. */
function applyCustomHotkeys(appHotkeys, customHotkeys) {
const custom = customHotkeys;
const result = {};
for (const [id, appHotkey] of Object.entries(appHotkeys)) {
const hotkey = custom[id] === void 0 ? appHotkey.defaultHotkey : custom[id];
result[id] = hotkey === appHotkey.hotkey ? appHotkey : {
...appHotkey,
hotkey
};
}
return result;
}
/** Keeps only valid custom hotkeys for known ids, warning about the rest. */
function sanitizeCustomHotkeys(appHotkeys, saved) {
const result = {};
if (typeof saved !== "object" || saved === null) return result;
for (const [id, value] of Object.entries(saved)) {
if (value === void 0) continue;
if (!(id in appHotkeys)) {
console.warn(`Ignoring custom hotkey for unknown app hotkey '${id}'`);
continue;
}
const hotkey = parseCustomHotkey(value);
if (hotkey === void 0) {
console.warn(`Ignoring invalid custom hotkey for app hotkey '${id}':`, value);
continue;
}
result[id] = hotkey;
}
return result;
}
/** Whether two app hotkeys can apply at the same time. */
function scopesOverlap(a, b) {
const left = a.scope ?? "global";
const right = b.scope ?? "global";
return left === "global" || right === "global" || left === right;
}
/** Whether two hotkeys collide: equal chords, or one sequence starts with the other. */
function hotkeysOverlap(left, right, platform) {
const a = isHotkeySequence(left) ? left : [left];
const b = isHotkeySequence(right) ? right : [right];
const length = Math.min(a.length, b.length);
for (let i = 0; i < length; i++) if (!areHotkeysEqual(a[i], b[i], platform)) return false;
return true;
}
/**
* Holds declared app hotkeys and the user's custom hotkeys. Framework
* providers create one and expose it through hooks; devtools read it from
* `HotkeyManager.appHotkeys`.
*
* @example
* ```ts
* const store = new AppHotkeysStore(appHotkeys, {
* customHotkeys: JSON.parse(localStorage.getItem('hotkeys') ?? '{}'),
* onCustomHotkeysChange: (custom) =>
* localStorage.setItem('hotkeys', JSON.stringify(custom)),
* })
* store.setCustomHotkey('doc.save', 'Mod+Shift+S')
* ```
*/
var AppHotkeysStore = class {
store;
#declarations;
#options;
constructor(appHotkeys, options = {}) {
this.#declarations = appHotkeys;
this.#options = options;
const customHotkeys = sanitizeCustomHotkeys(appHotkeys, options.customHotkeys);
this.store = new Store({
appHotkeys: applyCustomHotkeys(appHotkeys, customHotkeys),
customHotkeys
});
}
get #platform() {
return this.#options.platform ?? detectPlatform();
}
/** Effective app hotkeys. */
get appHotkeys() {
return this.store.state.appHotkeys;
}
/** The user's hotkeys that differ from the defaults. */
get customHotkeys() {
return this.store.state.customHotkeys;
}
/** Updates callbacks and other options without resetting custom hotkeys. */
setOptions(options) {
this.#options = {
...this.#options,
...options
};
}
/**
* Replaces the declarations, for example after hot module replacement.
* Custom hotkeys for ids that still exist are kept.
*/
setAppHotkeys(appHotkeys) {
if (appHotkeys === this.#declarations) return;
this.#declarations = appHotkeys;
const customHotkeys = sanitizeCustomHotkeys(appHotkeys, this.store.state.customHotkeys);
this.store.setState(() => ({
appHotkeys: applyCustomHotkeys(appHotkeys, customHotkeys),
customHotkeys
}));
}
/** Effective app hotkey for an id, or `undefined` when it is not declared. */
get(id) {
return this.store.state.appHotkeys[id];
}
/**
* Other app hotkeys that would collide if `id` used `hotkey`. App hotkeys
* collide when their scopes match or either is `'global'`, and their
* hotkeys are equal or one sequence starts with the other.
*/
findConflicts(id, hotkey) {
const self = this.store.state.appHotkeys[id];
if (!self) return [];
const candidate = simplifyHotkey(hotkey);
return Object.values(this.store.state.appHotkeys).filter((other) => other.id !== id && other.hotkey !== null && scopesOverlap(self, other) && hotkeysOverlap(candidate, other.hotkey, this.#platform));
}
/**
* Sets the user's hotkey for an app hotkey. `null` unbinds it. A hotkey
* equal to the default removes the customization.
*
* @returns Conflicting app hotkeys. When any exist, nothing changes.
*/
setCustomHotkey(id, hotkey) {
const appHotkey = this.store.state.appHotkeys[id];
if (!appHotkey) {
console.warn(`Cannot set a custom hotkey for unknown app hotkey '${id}'`);
return [];
}
const next = hotkey === null ? null : simplifyHotkey(hotkey);
if (next !== null) {
const conflicts = this.findConflicts(id, next);
if (conflicts.length > 0) return conflicts;
}
const { [id]: _previous, ...rest } = this.store.state.customHotkeys;
this.#commit(hotkeysEqual(next, appHotkey.defaultHotkey, this.#platform) ? rest : {
...rest,
[id]: next
});
return [];
}
/** Restores the default hotkey for an app hotkey. */
resetCustomHotkey(id) {
const { [id]: _previous, ...rest } = this.store.state.customHotkeys;
this.#commit(rest);
}
/** Restores every default hotkey. */
resetCustomHotkeys() {
this.#commit({});
}
#commit(customHotkeys) {
this.store.setState(() => ({
appHotkeys: applyCustomHotkeys(this.#declarations, customHotkeys),
customHotkeys
}));
this.#options.onCustomHotkeysChange?.(customHotkeys);
}
/**
* Joins app hotkeys with live registrations, in declaration order.
* Adapters call this with the manager's registration views.
*/
getList(registrations) {
const byId = /* @__PURE__ */ new Map();
for (const registration of registrations) {
const id = registration.options.appHotkeyId;
if (id === void 0) continue;
const list = byId.get(id);
if (list) list.push(registration);
else byId.set(id, [registration]);
}
return Object.values(this.store.state.appHotkeys).map((appHotkey) => {
const registered = byId.get(appHotkey.id) ?? [];
const has = (status) => registered.some((r) => r.status === status);
return {
appHotkey,
status: appHotkey.hotkey === null ? "unbound" : registered.length === 0 ? "inactive" : has("active") ? "active" : has("suspended") ? "suspended" : "disabled",
isCustom: isCustomHotkey(appHotkey, this.#platform),
registrations: registered
};
});
}
};
/**
* Returns the provider's version of an app hotkey, so custom hotkeys apply
* even when a component imports the declaration directly. Falls back to the
* given app hotkey when no store declares it.
*/
function resolveAppHotkey(appHotkey, store) {
return store?.get(appHotkey.id) ?? appHotkey;
}
/**
* Options that adapters pass to the manager when registering an app hotkey:
* declared options, scope, meta, and the app hotkey id. The manager drops options
* that do not apply to the effective hotkey's kind.
*/
function getAppHotkeyRegistrationOptions(appHotkey) {
return {
...appHotkey.options,
...appHotkey.scope !== void 0 && { scope: appHotkey.scope },
meta: appHotkey.meta,
appHotkeyId: appHotkey.id
};
}
/**
* Publishes a provider's store on the HotkeyManager so devtools can list
* declared hotkeys. Returns a cleanup function that unpublishes it if it is
* still the published store.
*/
function publishAppHotkeysStore(store) {
const manager = getHotkeyManager();
manager.appHotkeys.setState(() => store);
return () => {
if (manager.appHotkeys.state === store) manager.appHotkeys.setState(() => null);
};
}
/**
* Creates an app hotkeys store and publishes it on the HotkeyManager, for
* adapters without a provider component (such as Lit and Alpine). Hotkey
* APIs in those adapters resolve app hotkeys and custom hotkeys from the
* published store. Call it once at startup. Returns the store and a function
* that unpublishes it.
*
* @example
* ```ts
* provideAppHotkeys(appHotkeys, {
* customHotkeys: JSON.parse(localStorage.getItem('hotkeys') ?? '{}'),
* onCustomHotkeysChange: (custom) =>
* localStorage.setItem('hotkeys', JSON.stringify(custom)),
* })
* ```
*/
function provideAppHotkeys(appHotkeys, options = {}) {
const store = new AppHotkeysStore(appHotkeys, options);
return {
store,
dispose: publishAppHotkeysStore(store)
};
}
//#endregion
export { AppHotkeysStore, defineAppHotkeys, getAppHotkeyRegistrationOptions, isAppHotkey, isCustomHotkey, provideAppHotkeys, publishAppHotkeysStore, resolveAppHotkey };