UNPKG

@tanstack/hotkeys

Version:

Type-safe, framework-agnostic keyboard hotkey management for the browser

741 lines (739 loc) • 29.6 kB
import { normalizeKeyName } from "./constants.js"; import { detectPlatform } from "./platform.js"; import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js"; import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js"; import { formatHotkey } from "./format.js"; import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js"; import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js"; import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent, targetHasFocus } from "./_event-target.js"; import { TargetListeners } from "./_target-listeners.js"; import { managerRef } from "./_manager-ref.js"; import { isRecordingEvent } from "./_recording-guard.js"; import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js"; import { Store } from "@tanstack/store"; //#region src/hotkey-manager.ts /** Whether a registration can fire now. Without a manager, scopes don't apply. */ function getRegistrationStatus(options, manager) { if (options.enabled === false) return "disabled"; return manager?.isScopeActive(options.scope) === false ? "suspended" : "active"; } /** Creates a public view from an internal registration, stripping the callback. */ function toHotkeyRegistrationView(reg) { const base = { id: reg.id, status: getRegistrationStatus(reg.options, managerRef.current), target: reg.target, triggerCount: reg.triggerCount, hasFired: reg.hasFired }; if (reg.kind === "chord") return { ...base, kind: "chord", hotkey: reg.hotkey, parsedChord: reg.parsedChord, options: reg.options }; const length = reg.parsedSequence.length; const inProgress = reg.currentIndex > 0 && reg.currentIndex < length; return { ...base, kind: "sequence", hotkey: reg.hotkey, parsedSequence: reg.parsedSequence, options: reg.options, matchedStepCount: inProgress ? reg.currentIndex : 0, partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0 }; } let registrationIdCounter = 0; /** The string a chord registers as: strings as written, objects formatted from their parsed form. */ function chordString(chord, parsed) { return typeof chord === "string" ? chord : formatHotkey(parsed); } /** * Generates a unique ID for hotkey registrations. */ function generateId() { return `hotkey_${++registrationIdCounter}`; } /** Drops options that do not apply to a registration's kind. */ function optionsForKind(kind, options) { if (kind === "chord") { const { timeout: _timeout, ...rest } = options; return rest; } const { requireReset: _requireReset, ...rest } = options; return rest; } /** Steps with Mod already resolved for the registration's platform. */ function resolvedSteps(registration) { return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence; } /** Whether a registration skips keys that focused controls use, resolving `'auto'`. */ function ignoresInputs(registration) { const { ignoreInputs = "auto" } = registration.options; return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs; } function sameResolvedSteps(left, right) { return left.length === right.length && left.every((step, index) => parsedChordsEqual(step, right[index])); } function formatForMessage(hotkey) { return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey; } /** * Singleton manager for hotkey registrations, including sequences. * * This class provides a centralized way to register and manage keyboard * hotkeys. It shares one keydown and one keyup listener per target and * capture phase across registrations. * * Sequences (Vim-style hotkeys such as `['G', 'G']`) match when every step is * pressed within the timeout. Modifier-only events (Shift, Control, Alt, or * Meta alone) neither advance nor reset a sequence, so chained modifier chords * such as `['Shift+R', 'Shift+T']` work. * * @example * ```ts * const manager = HotkeyManager.getInstance() * * const save = manager.register('Mod+S', (event, context) => { * console.log('Save triggered!') * }) * const top = manager.register(['G', 'G'], () => scrollToTop(), { timeout: 500 }) * * // Later, to unregister: * save.unregister() * top.unregister() * ``` */ var HotkeyManager = class HotkeyManager { /** * The TanStack Store containing all hotkey registrations, chords and sequences. * Use this to subscribe to registration changes or access current registrations. * * @example * ```ts * const manager = HotkeyManager.getInstance() * * // Subscribe to registration changes * const unsubscribe = manager.registrations.subscribe(() => { * console.log('Registrations changed:', manager.registrations.state.size) * }) * * // Access current registrations * for (const [id, reg] of manager.registrations.state) { * console.log(reg.hotkey, reg.options.enabled) * } * ``` */ registrations = new Store(/* @__PURE__ */ new Map()); /** * The app hotkeys store published by a framework provider, so tools such as * devtools can list declared hotkeys that are not registered. */ appHotkeys = new Store(null); /** Active scopes. Subscribe to show which hotkeys are suspended. */ scopes = new Store({ active: [], exclusive: null, allowed: [], suspended: [] }); #platform; #scopeActivations = /* @__PURE__ */ new Set(); #scopeActivationCount = 0; /** Active scopes, lowest precedence first, derived from activations. */ #scopeOrder = []; /** Scopes that can fire while a scope is exclusive, or `null` when none is. */ #liveScopes = null; #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args)); /** Events that continue a sequence in progress, decided before any listener runs. */ #sequenceOwned = /* @__PURE__ */ new WeakMap(); /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */ #sequenceOwnedKeys = /* @__PURE__ */ new Set(); /** Timers that clear a sequence's progress once its timeout passes. */ #sequenceResetTimers = /* @__PURE__ */ new Map(); constructor() { this.#platform = detectPlatform(); } /** * Gets the singleton instance of HotkeyManager. */ static getInstance() { return managerRef.current ??= new HotkeyManager(); } /** * Resets the singleton instance. Useful for testing. */ static resetInstance() { managerRef.current?.destroy(); managerRef.current = null; } /** * Activates a scope until the returned function is called. Activations are * counted, so a scope stays active until every activation is released. * Framework providers call this while mounted. * * When active scopes share a hotkey, a nested scope takes precedence over * its ancestors, and otherwise the most recently activated scope wins. The * same order picks the exclusive scope when several are active, and only * that scope's `allow` list applies. * * @example * ```ts * const release = manager.activateScope('dialog', { exclusive: true }) * // Only hotkeys with scope 'dialog', or scopes nested in it, fire now. * release() * * manager.activateScope('dialog', { exclusive: { allow: ['global'] } }) * // Hotkeys with scope 'global' fire too. * ``` */ activateScope(scope, options = {}) { if (scope === "global") return () => {}; const { exclusive } = options; const allow = exclusive ? exclusive === true ? [] : exclusive.allow : null; if (allow && [...this.#scopeActivations].some((other) => other.allow && other.scope === scope)) console.warn(`Exclusive hotkey scope '${scope}' is already active. Hotkeys from both activations will fire; give each a unique scope.`); const activation = { scope, parent: options.parent ?? "global", allow, order: ++this.#scopeActivationCount }; this.#scopeActivations.add(activation); this.#updateScopes(); return () => { if (this.#scopeActivations.delete(activation)) this.#updateScopes(); }; } /** * Whether hotkeys in a scope can fire: the scope is `'global'` or * activated, and either no scope is exclusive or this scope is the * exclusive one, nested in it, or allowed by it. */ isScopeActive(scope = GLOBAL_SCOPE) { if (this.#liveScopes) return this.#liveScopes.has(scope); return scope === "global" || this.#scopeOrder.includes(scope); } /** Higher for scopes with higher precedence; `'global'` is 0. */ #scopeRank(scope = GLOBAL_SCOPE) { return this.#scopeOrder.indexOf(scope) + 1; } /** Recomputes scope precedence and publishes it. */ #updateScopes() { const parents = /* @__PURE__ */ new Map(); const latest = /* @__PURE__ */ new Map(); for (const activation of this.#scopeActivations) { parents.set(activation.scope, activation.parent); latest.set(activation.scope, Math.max(latest.get(activation.scope) ?? 0, activation.order)); } const depth = (scope) => { let result = 0; const seen = /* @__PURE__ */ new Set(); for (let current = parents.get(scope); current !== void 0 && !seen.has(current); current = parents.get(current)) { seen.add(current); result++; } return result; }; const since = /* @__PURE__ */ new Map(); for (const [scope, order] of latest) { const seen = /* @__PURE__ */ new Set(); for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) { seen.add(current); const own = latest.get(current); if (own !== void 0) since.set(current, Math.min(since.get(current) ?? own, order)); } } const compare = (a, b) => since.get(a) - since.get(b) || depth(a) - depth(b) || latest.get(a) - latest.get(b); this.#scopeOrder = [...latest.keys()].sort(compare); const exclusive = [...this.#scopeActivations].filter((activation) => activation.allow).sort((a, b) => compare(a.scope, b.scope) || a.order - b.order).pop(); const activated = [GLOBAL_SCOPE, ...this.#scopeOrder]; let allowed = []; this.#liveScopes = null; if (exclusive?.allow) { const { allow } = exclusive; const isNested = (scope) => { const seen = /* @__PURE__ */ new Set(); for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) { if (current === exclusive.scope) return true; seen.add(current); } return false; }; allowed = activated.filter((scope) => !isNested(scope) && allow.includes(scope)); this.#liveScopes = /* @__PURE__ */ new Set([...activated.filter(isNested), ...allowed]); } const live = this.#liveScopes; this.scopes.setState(() => ({ active: this.#scopeOrder, exclusive: exclusive?.scope ?? null, allowed, suspended: live ? activated.filter((scope) => !live.has(scope)) : [] })); this.#notify(); } /** Whether a registration can fire now: enabled, in an active scope. */ #isLive(registration) { return getRegistrationStatus(registration.options, this) === "active"; } /** * Whether a hotkey that can fire would handle this key on any listener the * event reaches, including other targets and phases. A sequence counts when * the key matches its next step or its first step. */ #handledByLiveHotkey(event, eventType) { const path = event.composedPath(); for (const registration of this.registrations.state.values()) { if (!this.#isLive(registration) || registration.options.eventType !== eventType || !path.includes(registration.target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, registration.target, registration.target)) continue; if ((registration.kind === "chord" ? [registration.parsedChord] : [registration.parsedSequence[registration.currentIndex], registration.parsedSequence[0]]).some((step) => matchKeyboardEvent(event, step, registration.options.platform).matched)) return true; } return false; } /** Notifies subscribers after in-place updates such as counts and sequence progress. */ #notify() { this.registrations.setState((prev) => new Map(prev)); } register(input, callback, options = {}) { const hotkey = simplifyHotkey(input); if (isHotkeySequence(hotkey) && hotkey.length === 0) throw new Error("Sequence must contain at least one hotkey"); if (typeof document === "undefined" && !options.target) { let currentCallback = callback; return { id: generateId(), unregister: () => {}, get callback() { return currentCallback; }, set callback(newCallback) { currentCallback = newCallback; }, setOptions: () => {}, get isActive() { return false; } }; } const id = generateId(); const platform = options.platform ?? this.#platform; const target = options.target ?? document; const sequence = isHotkeySequence(hotkey); const common = { id, callback, options: { ...defaultHotkeyOptions, ...sequence ? { timeout: DEFAULT_SEQUENCE_TIMEOUT } : { requireReset: false }, ...optionsForKind(sequence ? "sequence" : "chord", options), platform, ignoreInputs: options.ignoreInputs ?? "auto" }, target, triggerCount: 0, hasFired: false }; let registration; if (sequence) { const parsedSequence = parseHotkey(hotkey, platform); registration = { ...common, kind: "sequence", hotkey: hotkey.map((step, index) => chordString(step, parsedSequence[index])), parsedSequence, currentIndex: 0, lastKeyTime: 0 }; } else { const parsedChord = parseHotkey(hotkey, platform); registration = { ...common, kind: "chord", hotkey: chordString(hotkey, parsedChord), parsedChord }; } const conflicting = this.#findConflictingRegistration(registration); if (conflicting) handleConflict(conflicting.id, formatForMessage(registration.hotkey), options.conflictBehavior ?? "warn", (conflictId) => this.#unregister(conflictId)); this.registrations.setState((prev) => new Map(prev).set(id, registration)); this.#listeners.add(target, !!registration.options.capture, id); const manager = this; return { get id() { return id; }, unregister: () => { manager.#unregister(id); }, get callback() { return manager.registrations.state.get(id)?.callback ?? callback; }, set callback(newCallback) { const reg = manager.registrations.state.get(id); if (reg) reg.callback = newCallback; }, setOptions: (newOptions) => { manager.registrations.setState((prev) => { const reg = prev.get(id); if (!reg) return prev; const options = { ...reg.options, ...optionsForKind(reg.kind, newOptions) }; if (optionsEqual(reg.options, options)) return prev; const capture = !!reg.options.capture; if (capture !== !!options.capture) { manager.#listeners.remove(reg.target, capture, id); manager.#listeners.add(reg.target, !!options.capture, id); } return new Map(prev).set(id, { ...reg, options }); }); }, get isActive() { return manager.registrations.state.has(id); } }; } /** * Unregisters a hotkey by its registration ID. */ #unregister(id) { const registration = this.registrations.state.get(id); if (!registration) return; const target = registration.target; this.registrations.setState((prev) => { const next = new Map(prev); next.delete(id); return next; }); this.#listeners.remove(target, !!registration.options.capture, id); this.#clearSequenceReset(id); } /** * Clears a sequence's progress, and notifies subscribers, once its timeout * passes without another step. Key presses check the timeout too, so this * only keeps views such as `matchedStepCount` current. */ #scheduleSequenceReset(id, delay) { this.#clearSequenceReset(id); this.#sequenceResetTimers.set(id, setTimeout(() => { this.#sequenceResetTimers.delete(id); const registration = this.registrations.state.get(id); if (registration?.kind !== "sequence" || registration.currentIndex === 0) return; const timeout = registration.options.timeout ?? 1e3; const remaining = registration.lastKeyTime + timeout - Date.now(); if (remaining >= 0) { this.#scheduleSequenceReset(id, remaining + 1); return; } registration.currentIndex = 0; registration.lastKeyTime = 0; this.#notify(); }, delay)); } #clearSequenceReset(id) { const timer = this.#sequenceResetTimers.get(id); if (timer === void 0) return; clearTimeout(timer); this.#sequenceResetTimers.delete(id); } /** Registrations of one kind in the listener phase, in registration order. */ #registrationsIn(ids, capture, kind) { const result = []; for (const id of ids) { const registration = this.registrations.state.get(id); if (registration?.kind === kind && !!registration.options.capture === capture) result.push(registration); } return result; } /** The live registration, unless it was removed or moved to another phase. */ #currentIn(registration, capture) { const current = this.registrations.state.get(registration.id); return current && !!current.options.capture === capture ? current : void 0; } /** * Processes keyboard events for a specific target and event type. * A key that continues a sequence in progress belongs to that sequence, so * chords skip it. Chords and first steps of sequences may both fire. */ #processTargetEvent(event, target, eventType, capture, ids, first) { if (first) this.#sequenceOwned.set(event, this.#isOwnedBySequence(event, eventType)); this.#processChords(event, target, eventType, capture, this.#registrationsIn(ids, capture, "chord")); this.#processSequences(event, target, eventType, capture, this.#registrationsIn(ids, capture, "sequence")); } #processChords(event, target, eventType, capture, registrations) { if (eventType === "keyup") { for (const registration of registrations) if (registration.hasFired && this.#shouldResetRegistration(registration, event)) { registration.hasFired = false; registration.activeMatch = void 0; } } if (isRecordingEvent(event) || this.#sequenceOwned.get(event)) return; const matches = /* @__PURE__ */ new Map(); let bestScore = 0; let reserved = false; for (const registration of registrations) { const live = this.#isLive(registration); if (!live && registration.options.preventDefault !== "always" || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue; const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform); if (!result.matched) continue; if (!live) { reserved = true; continue; } matches.set(registration.id, result); bestScore = Math.max(bestScore, result.score); } if (reserved && matches.size === 0 && !this.#handledByLiveHotkey(event, eventType)) event.preventDefault(); let bestRank = -1; for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope)); for (const snapshot of registrations) { const match = matches.get(snapshot.id); if (!match?.matched || match.score !== bestScore || this.#scopeRank(snapshot.options.scope) !== bestRank) continue; const registration = this.#currentIn(snapshot, capture); if (!registration) continue; this.#consume(registration, event); if (eventType === "keyup") { this.#fire(registration, event); continue; } if (!registration.options.requireReset || !registration.hasFired) { if (registration.options.requireReset) { registration.hasFired = true; registration.activeMatch = match.identity; } this.#fire(registration, event); } } } /** * Whether the event continues or completes a sequence in progress whose * target the event reaches. The repeats and keyup of a keydown that a * sequence owns belong to the sequence too. */ #isOwnedBySequence(event, eventType) { const key = event.code || normalizeKeyName(event.key); if (eventType === "keyup") { const ownedKeydown = this.#sequenceOwnedKeys.delete(key); return this.#continuesSequence(event, eventType) || ownedKeydown; } if (event.repeat) return this.#sequenceOwnedKeys.has(key); const owned = this.#continuesSequence(event, eventType); if (owned) this.#sequenceOwnedKeys.add(key); else this.#sequenceOwnedKeys.delete(key); return owned; } #continuesSequence(event, eventType) { if (isRecordingEvent(event) || normalizeKeyboardEvent(event).isComposing || isModifierKey(normalizeKeyName(event.key))) return false; const path = event.composedPath(); const now = Date.now(); for (const registration of this.registrations.state.values()) { if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue; const target = registration.target; if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true; } return false; } #processSequences(event, target, eventType, capture, registrations) { if (registrations.length === 0) return; if (isRecordingEvent(event)) { let changed = false; for (const registration of registrations) if (registration.currentIndex > 0) { registration.currentIndex = 0; this.#clearSequenceReset(registration.id); changed = true; } if (changed) this.#notify(); return; } if (event.type === "keydown" && event.repeat || normalizeKeyboardEvent(event).isComposing) return; if (isModifierKey(normalizeKeyName(event.key))) return; const now = Date.now(); let changed = false; const candidates = /* @__PURE__ */ new Map(); let bestScore = 0; for (const registration of registrations) { if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue; const timeout = registration.options.timeout ?? 1e3; if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) { registration.currentIndex = 0; changed = true; } const expected = registration.parsedSequence[registration.currentIndex]; if (!expected) continue; let match = matchKeyboardEvent(event, expected, registration.options.platform); let nextIndex = registration.currentIndex + 1; if (!match.matched && registration.currentIndex > 0) { match = matchKeyboardEvent(event, registration.parsedSequence[0], registration.options.platform); nextIndex = 1; } candidates.set(registration.id, { score: match.score, nextIndex }); bestScore = Math.max(bestScore, match.score); } const completed = []; for (const registration of registrations) { const candidate = candidates.get(registration.id); if (!candidate) continue; if (candidate.score > 0 && candidate.score === bestScore) { registration.lastKeyTime = now; registration.currentIndex = candidate.nextIndex; changed = true; if (registration.currentIndex >= registration.parsedSequence.length) { this.#consume(registration, event); registration.currentIndex = 0; completed.push(registration); } else if (registration.options.preventDefault) event.preventDefault(); } else if (registration.currentIndex > 0) { registration.currentIndex = 0; changed = true; } } for (const registration of registrations) { if (!candidates.has(registration.id)) continue; if (registration.currentIndex > 0) { const timeout = registration.options.timeout ?? 1e3; this.#scheduleSequenceReset(registration.id, timeout + 1); } else this.#clearSequenceReset(registration.id); } if (changed && completed.length === 0) this.#notify(); for (const snapshot of completed) { const registration = this.#currentIn(snapshot, capture); if (registration) this.#fire(registration, event); } } /** Applies a matched registration's preventDefault and stopPropagation options. */ #consume(registration, event) { if (registration.options.preventDefault) event.preventDefault(); if (registration.options.stopPropagation) event.stopPropagation(); } /** Counts a trigger, notifies subscribers (such as devtools), and runs the callback. */ #fire(registration, event, context = this.#callbackContext(registration)) { registration.triggerCount++; if (registration.kind === "sequence") registration.hasFired = true; this.#notify(); registration.callback(event, context); } #callbackContext(registration) { return registration.kind === "chord" ? { kind: "chord", hotkey: registration.hotkey, parsedChord: registration.parsedChord } : { kind: "sequence", hotkey: registration.hotkey, parsedSequence: registration.parsedSequence }; } /** * Finds an existing registration on the same target and scope whose steps * resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS * and `Mod+S` on Windows are different bindings. */ #findConflictingRegistration(candidate) { const steps = resolvedSteps(candidate); for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration; return null; } /** * Determines if a registration should be reset based on the keyup event. */ #shouldResetRegistration(registration, event) { const parsed = registration.parsedChord; const releasedKey = normalizeKeyName(event.key); if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true; return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey); } /** * Resets the progress of every in-progress sequence. */ resetSequenceProgress() { for (const registration of this.registrations.state.values()) if (registration.kind === "sequence") { registration.currentIndex = 0; registration.lastKeyTime = 0; } for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer); this.#sequenceResetTimers.clear(); this.#notify(); } /** * Triggers a registration's callback programmatically from devtools. * Creates a synthetic KeyboardEvent from the chord (or a sequence's last step) * and invokes the callback. * * @param id - The registration ID to trigger * @returns True if the registration was found and triggered */ triggerRegistration(id) { const registration = this.registrations.state.get(id); if (!registration) return false; const context = this.#callbackContext(registration); const parsed = registration.kind === "chord" ? registration.parsedChord : registration.parsedSequence[registration.parsedSequence.length - 1]; const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", { ...parsed.code !== void 0 ? { code: parsed.code } : { key: parsed.key }, ctrlKey: parsed.ctrl, shiftKey: parsed.shift, altKey: parsed.alt, metaKey: parsed.meta, bubbles: true, cancelable: true }); this.#fire(registration, syntheticEvent, context); return true; } /** * Gets the number of registered hotkeys, chords and sequences. */ getRegistrationCount() { return this.registrations.state.size; } /** * Checks if a specific hotkey is registered. * * @param hotkey - The chord or sequence to check * @param target - Optional target element to match (if provided, both hotkey and target must match) * @returns True if a matching registration exists */ isRegistered(hotkey, target) { for (const registration of this.registrations.state.values()) if (areHotkeysEqual(registration.hotkey, hotkey, registration.options.platform) && (target === void 0 || registration.target === target)) return true; return false; } /** * Checks whether a hotkey can fire now: at least one registration is * enabled, in an active scope, and listening where key presses go. A * registration on `document` or `window` always is; one on an element is * only while focus is inside that element. An app hotkey matches * registrations by id, so custom hotkeys apply. * * @param hotkey - A chord, sequence, or app hotkey * @returns True if a registration that can fire exists */ hasActiveRegistration(hotkey) { const id = typeof hotkey === "object" && "~appHotkey" in hotkey ? hotkey.id : void 0; for (const registration of this.registrations.state.values()) if (this.#isLive(registration) && targetHasFocus(registration.target) && (id === void 0 ? areHotkeysEqual(registration.hotkey, hotkey, registration.options.platform) : registration.options.appHotkeyId === id)) return true; return false; } /** * Destroys the manager and removes all listeners. */ destroy() { for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer); this.#sequenceResetTimers.clear(); this.#listeners.destroy(); this.registrations.setState(() => /* @__PURE__ */ new Map()); this.appHotkeys.setState(() => null); } }; /** * Gets the singleton HotkeyManager instance. * Convenience function for accessing the manager. */ function getHotkeyManager() { return HotkeyManager.getInstance(); } //#endregion export { HotkeyManager, getHotkeyManager, toHotkeyRegistrationView };