rx-hotkeys
Version:
Advanced Keyboard Shortcut Management library using rxjs
767 lines • 37.2 kB
JavaScript
import { fromEvent, BehaviorSubject, EMPTY, filter, map, bufferCount, withLatestFrom, tap, catchError, scan, merge, Subject, takeUntil, share, distinctUntilChanged, combineLatest, } from "rxjs";
import { Keys, KeyAliases } from "./keys.js";
// --- Enums, Interfaces and Types ---
export var ShortcutTypes;
(function (ShortcutTypes) {
ShortcutTypes["Combination"] = "combination";
ShortcutTypes["Sequence"] = "sequence";
})(ShortcutTypes || (ShortcutTypes = {}));
var EmitStates;
(function (EmitStates) {
EmitStates[EmitStates["Emit"] = 0] = "Emit";
EmitStates[EmitStates["Ignore"] = 1] = "Ignore";
EmitStates[EmitStates["InProgress"] = 2] = "InProgress";
})(EmitStates || (EmitStates = {}));
// --- Helper function to compare keys ---
/**
* Compares a browser event's key with a configured key.
* - For single character keys (e.g., "a", "A", "7"), comparison is case-insensitive.
* - For multi-character special keys (e.g., "Enter", "ArrowUp"), comparison is case-sensitive.
* @param eventKey The `key` property from the `KeyboardEvent`.
* @param configuredKey The key string from `Keys` used in the configuration.
* @returns True if the keys match according to the rules, false otherwise.
*/
function compareKey(eventKey, configuredKey) {
if (configuredKey.length === 1 && eventKey.length === 1) {
return eventKey.toLowerCase() === configuredKey.toLowerCase();
}
return eventKey === configuredKey;
}
/**
* Normalizes a string representation of a key into a canonical StandardKey.
* Handles case-insensitivity, aliases, and special characters.
* @param key The raw key string to normalize.
* @returns A StandardKey if valid, otherwise null.
*/
function normalizeKey(key) {
// 1. Handle spacebar explicitly to avoid trimming
if (key === Keys.Space) {
return Keys.Space;
}
// 2. Trim and convert to lower case for consistent matching
const normalizedStr = key.trim().toLowerCase();
if (normalizedStr === "") {
return null;
}
// 3. Look up in aliases, then in standard key values, then check for single char
const finalKey = KeyAliases[normalizedStr] ||
Object.values(Keys).find(k => k.toLowerCase() === normalizedStr) ||
(normalizedStr.length === 1 ? normalizedStr.toUpperCase() : undefined);
return finalKey || null;
}
// --- Hotkeys Library ---
/**
* Manages keyboard shortcuts for web applications.
* Allows registration of single key combinations (e.g., Ctrl+S) and key sequences (e.g., g -> i).
* Supports contexts to enable/disable shortcuts based on application state.
*/
export class Hotkeys {
static KEYDOWN_EVENT = "keydown";
static KEYUP_EVENT = "keyup";
static LOG_PREFIX = "Hotkeys:";
// --- Sentinel value for no override ---
static NO_OVERRIDE = Symbol("No Hotkey Override");
// NEW: A unified stream cache that handles different listener options.
eventStreams;
activeShortcuts;
debugMode;
// --- Separate states for stack and override ---
contextStack$;
overrideContext$;
/**
* An Observable that emits the new active context name (or null) whenever it changes.
* The active context is the override context if one is set, otherwise it's the context
* from the top of the stack.
*/
activeContext$;
/**
* Creates an instance of Hotkeys.
* @param initialContext - Optional initial context name. This forms the base of the context stack.
* @param debugMode - Optional. If true, debug messages will be logged to the console. Defaults to false.
* @throws Error if not in a browser environment (i.e., `document` or `performance` is undefined).
*/
constructor(initialContext = null, debugMode = false) {
this.debugMode = debugMode;
if (typeof document === "undefined" || typeof performance === "undefined") {
throw new Error(`${Hotkeys.LOG_PREFIX} Hotkeys can only be used in a browser environment.`);
}
this.eventStreams = new WeakMap(); // Initialize the new unified cache
this.activeShortcuts = new Map();
// The context stack is the source of truth for the active context.
this.contextStack$ = new BehaviorSubject([initialContext]);
this.overrideContext$ = new BehaviorSubject(Hotkeys.NO_OVERRIDE);
// The public activeContext$ now correctly handles the sentinel value.
this.activeContext$ = combineLatest([
this.overrideContext$,
this.contextStack$.pipe(map(stack => stack.length > 0 ? stack[stack.length - 1] : null))
]).pipe(map(([overrideCtx, stackCtx]) => this._resolveActiveContext(overrideCtx, stackCtx)), distinctUntilChanged());
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Library initialized. Initial context: "${initialContext}". Debug mode: ${debugMode}.`);
// Optional: Log context changes for debugging
this.activeContext$.subscribe(newContext => {
console.log(`${Hotkeys.LOG_PREFIX} Active context changed to: ${newContext}`);
});
}
}
/**
* Helper method to determine the active context based on override and stack.
*/
_resolveActiveContext(overrideCtx, stackCtx) {
return overrideCtx !== Hotkeys.NO_OVERRIDE ? overrideCtx : stackCtx;
}
_normalizeAndParseTriggers(keys, shortcutId) {
const keyInputs = Array.isArray(keys) ? keys : [keys];
const parsedTriggers = [];
for (const input of keyInputs) {
let triggersToParse = [];
if (typeof input === "string" && input.length > 1 && input.includes("+")) {
triggersToParse.push(...this._parseCombinationString(input));
}
else {
triggersToParse.push(input);
}
for (const trigger of triggersToParse) {
const parsed = this._parseKeyTrigger(trigger, shortcutId);
if (parsed) {
parsedTriggers.push({
key: parsed.configuredMainKey,
ctrlKey: !!parsed.ctrlKeyConfig,
altKey: !!parsed.altKeyConfig,
shiftKey: !!parsed.shiftKeyConfig,
metaKey: !!parsed.metaKeyConfig,
});
}
}
}
return parsedTriggers;
}
_normalizeSequence(sequence, shortcutId) {
const keyInputs = typeof sequence === "string" ? sequence.split("->") : sequence;
const results = [];
for (const keyStr of keyInputs) {
const finalKey = normalizeKey(typeof keyStr === "string" && keyStr.length > 1 ? keyStr.trim() : keyStr);
if (finalKey) {
results.push(finalKey);
}
else {
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${keyStr}" in sequence for shortcut "${shortcutId}".`);
return null; // Fail fast if any key is invalid
}
}
return results;
}
/**
* [PRIVATE] Generates a unique key for the stream cache based on event type and options.
*/
_getStreamCacheKey(eventType, options) {
if (!options) {
return eventType;
}
// Creates a stable key, e.g., "keydown:c=true:p=false:o=false"
const capture = !!options.capture;
const passive = !!options.passive;
const once = !!options.once;
return `${eventType}:c=${capture}:p=${passive}:o=${once}`;
}
/**
* Gets or creates a shared event stream for a given event type, target, and options.
* @param eventType The type of event ("keydown" or "keyup").
* @param target The DOM element to attach the listener to.
* @param options The AddEventListenerOptions.
* @returns A shared Observable for the specified event.
*/
_getEventStream(eventType, target, options) {
if (!this.eventStreams.has(target)) {
this.eventStreams.set(target, new Map());
}
const targetCache = this.eventStreams.get(target);
const cacheKey = this._getStreamCacheKey(eventType, options);
if (!targetCache.has(cacheKey)) {
// Create the new stream with the specified options
const newStream = fromEvent(target, eventType, options).pipe(share());
targetCache.set(cacheKey, newStream);
if (this.debugMode) {
const targetName = target === document ? "document" : `element "${target.id || target.tagName}"`;
console.log(`${Hotkeys.LOG_PREFIX} Created new shared listener for "${cacheKey}" on ${targetName}.`);
}
}
return targetCache.get(cacheKey);
}
/**
* Sets a temporary, high-priority override context that takes precedence over the context stack.
* @param contextName The override context to activate (can be a string or `null`).
* @returns A `restore` function that, when called, clears the override context, reverting to the stack.
*/
setContext(contextName) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Setting override context to: "${contextName}".`);
}
this.overrideContext$.next(contextName);
const restore = () => {
// Only clear the override if it's still the one we set.
if (this.overrideContext$.getValue() === contextName) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Restoring/clearing override context from: "${contextName}".`);
}
// Restore now sets the special "NO_OVERRIDE" value.
this.overrideContext$.next(Hotkeys.NO_OVERRIDE);
}
};
return restore;
}
/**
* @deprecated Rename to `getActiveContext`
* Gets the current active context, considering any override.
* @returns The current context name as a string, or `null` if no context is set.
*/
getContext() {
console.warn(`${Hotkeys.LOG_PREFIX} "getContext" is deprecated. Use "getActiveContext()" or subscribe to "onContextChange$" instead.`);
return this.getActiveContext();
}
/**
* Gets the current active context, considering any override.
* @returns The current context name as a string, or `null` if no context is set.
*/
getActiveContext() {
const overrideCtx = this.overrideContext$.getValue();
const stack = this.contextStack$.getValue();
const stackCtx = stack.length > 0 ? stack[stack.length - 1] : null;
// Also uses the abstracted helper method.
return this._resolveActiveContext(overrideCtx, stackCtx);
}
/**
* Pushes a new context onto the context stack. It will become active if no override context is set.
* @param contextName The name of the context to enter (e.g., "modal", "editor").
*/
enterContext(contextName) {
const currentStack = this.contextStack$.getValue();
const newStack = [...currentStack, contextName];
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Entering context: "${contextName}". New stack: [${newStack.join(", ")}]`);
}
this.contextStack$.next(newStack);
}
/**
* Pops the current context from the stack.
* @returns The context that was just left from the stack, or `undefined` if at the base.
*/
leaveContext() {
const currentStack = this.contextStack$.getValue();
if (currentStack.length <= 1) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Attempted to leave the base stack context. No change made.`);
}
return undefined; // Nothing was left
}
const leavingContext = currentStack[currentStack.length - 1];
const newStack = currentStack.slice(0, -1);
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Leaving context: "${leavingContext}". New stack: [${newStack.join(", ")}]`);
}
this.contextStack$.next(newStack);
return leavingContext;
}
/**
* Enables or disables debug logging for the Hotkeys instance.
* When enabled, various internal actions and shortcut triggers will be logged to the console.
* @param enable - True to enable debug logs, false to disable.
*/
setDebugMode(enable) {
if (this.debugMode === enable) {
return;
}
this.debugMode = enable;
if (enable) {
console.log(`${Hotkeys.LOG_PREFIX} Debug mode enabled.`);
}
else {
console.log(`${Hotkeys.LOG_PREFIX} Debug mode disabled.`);
}
}
/**
* Checks if a shortcut with the given ID is currently registered and active.
* @param id - The unique ID of the shortcut to check.
* @returns True if a shortcut with the specified ID exists, false otherwise.
*/
hasShortcut(id) {
return this.activeShortcuts.has(id);
}
/**
* An Observable that emits the new context name (or null) whenever the active context changes.
*
* @example
* ```typescript
* const hotkeys = new Hotkeys();
* const subscription = hotkeys.onContextChange$.subscribe(newContext => {
* console.log("Hotkey context changed to:", newContext);
* // Update UI or perform other actions
* });
* // To unsubscribe when no longer needed:
* // subscription.unsubscribe();
* ```
*/
get onContextChange$() {
return this.activeContext$;
}
/**
* Compares two sequences of StandardKey arrays to see if they are identical.
* @param seq1 - The first sequence array.
* @param seq2 - The second sequence array.
* @returns True if the sequences are identical, false otherwise.
*/
_areSequencesIdentical(seq1, seq2) {
if (seq1.length !== seq2.length) {
return false;
}
for (let i = 0; i < seq1.length; i++) {
if (seq1[i] !== seq2[i]) { // Direct comparison for canonical StandardKey values
return false;
}
}
return true;
}
/**
* Checks if a given KeyCombinationConfig matches a given KeyboardEvent.
* This is used internally for priority checking.
* @param shortcutConfig The KeyCombinationConfig to check.
* @param event The KeyboardEvent to match against.
* @returns True if the shortcutConfig matches the event, false otherwise.
*/
_shortcutMatchesEvent(parsedTriggers, event) {
// It no longer does any parsing. It just compares against the pre-parsed triggers.
for (const trigger of parsedTriggers) {
const keyMatch = compareKey(event.key, trigger.key);
if (!keyMatch)
continue;
if (event.ctrlKey === trigger.ctrlKey &&
event.altKey === trigger.altKey &&
event.shiftKey === trigger.shiftKey &&
event.metaKey === trigger.metaKey) {
return true;
}
}
return false;
}
filterByContext(source$, context, strict) {
return source$.pipe(withLatestFrom(this.activeContext$), filter(([/* event */ , activeCtx]) => {
if (context == null) {
if (strict) {
return activeCtx == null;
}
else {
return true;
}
}
else {
return context === activeCtx;
}
}), map(([event, /* _activeCtx */]) => event));
}
_registerShortcut(config, terminator$, type, detailsForLog, parsedTriggers) {
const existingShortcut = this.activeShortcuts.get(config.id);
if (existingShortcut) {
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut with ID "${config.id}" already exists. The old instance will be terminated and overwritten.`);
existingShortcut.terminator$.next();
existingShortcut.terminator$.complete();
}
this.activeShortcuts.set(config.id, { id: config.id, config, terminator$, parsedTriggers });
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} ${type} shortcut "${config.id}" added. ${detailsForLog}, Context: ${config.context ?? "any"}`);
}
}
/**
* Parses a single key trigger definition (either shorthand StandardKey or an object with modifiers)
* into its constituent parts: main key and modifier states.
* @param keyInput - The KeyCombinationTrigger to parse.
* @param shortcutId - The ID of the shortcut this key trigger belongs to (for logging).
* @returns An object containing configuredMainKey and modifier states, or null if parsing fails.
*/
_parseKeyTrigger(keyInput, shortcutId) {
if (typeof keyInput === "string") {
const finalKey = normalizeKey(keyInput);
if (!finalKey) {
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${keyInput}" in shortcut "${shortcutId}".`);
return null;
}
return {
configuredMainKey: finalKey,
ctrlKeyConfig: false,
altKeyConfig: false,
shiftKeyConfig: false,
metaKeyConfig: false,
};
}
else {
if (!keyInput.key || typeof keyInput.key !== "string" || keyInput.key === "") {
console.warn(`${Hotkeys.LOG_PREFIX} Invalid "key" property in shortcut "${shortcutId}". Key must be a non-empty string value from Keys.`);
return null;
}
return {
configuredMainKey: keyInput.key,
ctrlKeyConfig: keyInput.ctrlKey,
altKeyConfig: keyInput.altKey,
shiftKeyConfig: keyInput.shiftKey,
metaKeyConfig: keyInput.metaKey,
};
}
}
_parseCombinationString(shortcut) {
const parts = shortcut.toLowerCase().split("+").map(p => p.trim());
const mainKeyStr = parts.pop();
if (!mainKeyStr) {
console.warn(`${Hotkeys.LOG_PREFIX} Invalid shortcut string: "${shortcut}". No main key found.`);
return [];
}
const trigger = {
key: "",
ctrlKey: false, altKey: false, shiftKey: false, metaKey: false
};
const finalKey = normalizeKey(mainKeyStr);
if (!finalKey) {
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${mainKeyStr}" in shortcut string "${shortcut}".`);
return [];
}
trigger.key = finalKey;
for (const part of parts) {
if (part === "ctrl" || part === "control")
trigger.ctrlKey = true;
else if (part === "alt" || part === "option")
trigger.altKey = true;
else if (part === "shift")
trigger.shiftKey = true;
else if (part === "meta" || part === "cmd" || part === "command" || part === "win")
trigger.metaKey = true;
else
console.warn(`${Hotkeys.LOG_PREFIX} Unknown modifier: "${part}" in shortcut string "${shortcut}".`);
}
return [trigger];
}
_parseSequenceString(sequence) {
const keyStrings = sequence.split("->").map(k => k.trim());
const results = [];
for (const keyStr of keyStrings) {
const finalKey = normalizeKey(keyStr);
if (finalKey) {
results.push(finalKey);
}
else {
console.warn(`${Hotkeys.LOG_PREFIX} Could not parse key: "${keyStr}" in sequence string "${sequence}".`);
return []; // Fail fast
}
}
return results;
}
/**
* Registers a key combination shortcut (e.g., Ctrl+S, Shift+Enter, or a single key like Escape)
* and returns an Observable that emits the `KeyboardEvent` when the combination is triggered.
* @param config - Configuration object for the key combination.
* See {@link KeyCombinationConfig} for details.
* @returns An `Observable<KeyboardEvent>` that you can subscribe to. The stream will be automatically
* completed if the shortcut is removed via `remove(id)` or `destroy()`, or if it's overwritten.
* If the configuration is invalid, an empty Observable is returned and a warning is logged.
* @example
* ```typescript
* import { Keys } from "./keys";
* // For Ctrl+S
* const save$ = keyManager.addCombination({
* id: "saveFile",
* keys: { key: Keys.S, ctrlKey: true },
* context: "editor"
* });
* save$.subscribe(event => console.log("File saved!", event));
*
* // For Ctrl+S using a string
* const save$ = keyManager.addCombination({ id: "saveFile", keys: "ctrl+s" });
* save$.subscribe(event => console.log("File saved!", event));
*
* // For just the Escape key, or Ctrl+Space
* const close$ = keyManager.addCombination({
* id: "closeModal",
* keys: [Keys.Escape, {key: Keys.Space, ctrlKey: true}],
* });
* close$.subscribe(() => console.log("Modal closed!"));
*
* // For the Escape key on a specific element
* const myModal = document.getElementById("my-modal");
* const close$ = keyManager.addCombination({ id: "closeModal", keys: Keys.Escape, target: myModal });
* close$.subscribe(() => console.log("Modal closed!"));
* ```
*/
addCombination(config) {
const { keys, context, preventDefault = false, id, strict = false, target = document, event: eventType = "keydown", options } = config;
if (context != null && strict) {
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context(${context}) and the "strict" flag. The "strict" flag will be ignored.`);
}
const parsedTriggers = this._normalizeAndParseTriggers(keys, id);
if (parsedTriggers.length === 0) {
console.error(`${Hotkeys.LOG_PREFIX} "keys" definition for combination shortcut "${id}" is empty or invalid. Shortcut not added.`);
return EMPTY;
}
const sourceStream$ = this._getEventStream(eventType, target, options);
const observables = [];
for (const trigger of parsedTriggers) {
const stream = this.filterByContext(sourceStream$, context, strict).pipe(filter(event => {
return event.ctrlKey === trigger.ctrlKey &&
event.altKey === trigger.altKey &&
event.shiftKey === trigger.shiftKey &&
event.metaKey === trigger.metaKey;
}), filter(event => compareKey(event.key, trigger.key)),
// New filter for priority: Specific context > Global context
withLatestFrom(this.activeContext$), filter(([event, activeCtx]) => {
if (context != null || strict) { // This shortcut is NOT global or strict
return true;
}
// This shortcut IS global. Check for specific overrides.
if (activeCtx == null) { // No specific context active
return true;
}
for (const [, otherAS] of this.activeShortcuts) {
if (otherAS.config.id !== id &&
"keys" in otherAS.config &&
otherAS.config.context === activeCtx &&
this._shortcutMatchesEvent(otherAS.parsedTriggers ?? [], event)) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Global shortcut "${id}" (key: "${event.key}") suppressed by specific context shortcut "${otherAS.config.id}".`);
}
return false; // Suppress global
}
}
return true; // Global can proceed
}), map(([event]) => event));
observables.push(stream);
}
if (observables.length === 0) {
// This path should now be much harder to hit, but remains a safeguard.
console.warn(`${Hotkeys.LOG_PREFIX} No valid key triggers for combination shortcut "${id}". Shortcut not added.`);
return EMPTY;
}
const terminator$ = new Subject();
const finalShortcut$ = merge(...observables);
const logParts = parsedTriggers.map(t => {
const parts = [`key: "${t.key}"`];
if (t.ctrlKey)
parts.push("ctrl: true");
if (t.altKey)
parts.push("alt: true");
if (t.shiftKey)
parts.push("shift: true");
if (t.metaKey)
parts.push("meta: true");
// For a single key with no modifiers, match the original log format
if (parts.length === 1) {
return `{ ${parts[0]} (no mods) }`;
}
return `{ ${parts.join(", ")} }`;
});
const logDetails = `Triggers: [ ${logParts.join(", ")} ]`;
this._registerShortcut(config, terminator$, ShortcutTypes.Combination, logDetails, parsedTriggers);
return finalShortcut$.pipe(tap(event => {
if (this.debugMode) {
const preventAction = preventDefault ? ", preventing default" : "";
console.log(`${Hotkeys.LOG_PREFIX} Combination "${id}" triggered by key "${event.key}" ${preventAction}.`);
}
if (preventDefault)
event.preventDefault();
}), catchError(err => {
console.error(`${Hotkeys.LOG_PREFIX} Error in combination stream for shortcut "${id}":`, err);
return EMPTY;
}), takeUntil(terminator$));
}
/**
* Registers a key sequence shortcut (e.g., g -> i, or ArrowUp -> ArrowUp -> ArrowDown)
* and returns an Observable that emits the final `KeyboardEvent` of the sequence when it's completed.
* An optional timeout can be specified for the time allowed between key presses in the sequence.
* @param config - Configuration object for the key sequence.
* See {@link KeySequenceConfig} for details.
* Each key in the `sequence` array must be a value from the `Keys` object.
* Or using string for `sequence`.
* @returns An `Observable<KeyboardEvent>` that you can subscribe to. The stream will be automatically
* completed if the shortcut is removed via `remove(id)` or `destroy()`, or if it's overwritten.
* If the configuration is invalid, an empty Observable is returned and a warning is logged.
* @example
* ```typescript
* import { Keys } from "./keys";
* const konami$ = keyManager.addSequence({
* id: "konamiCode",
* sequence: [Keys.ArrowUp, Keys.ArrowUp, Keys.ArrowDown, Keys.ArrowDown, Keys.A, Keys.B],
* sequenceTimeoutMs: 2000 // 2 seconds between keys
* });
* konami$.subscribe(event => console.log("Konami!", event));
* ```
* ```typescript
* // Using a string for the sequence
* const konami$ = keyManager.addSequence({
* id: "konamiCode",
* sequence: "up -> up -> down -> down -> a -> b",
* sequenceTimeoutMs: 2000
* });
* konami$.subscribe(event => console.log("Konami!", event));
* ```
*/
addSequence(config) {
const { sequence, context, preventDefault = false, id, sequenceTimeoutMs, strict = false, target = document, event: eventType = "keydown", options } = config;
const configuredSequence = this._normalizeSequence(sequence, id);
if (!configuredSequence || configuredSequence.length === 0) {
console.error(`${Hotkeys.LOG_PREFIX} Sequence for shortcut "${id}" is empty or invalid. Shortcut not added.`);
return EMPTY;
}
if (context && strict) {
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" has both a context and the "strict" flag. The "strict" flag will be ignored.`);
}
const sequenceLength = configuredSequence.length;
let shortcut$;
const sourceStream$ = this._getEventStream(eventType, target, options);
const baseKeydownStream$ = this.filterByContext(sourceStream$, context, strict);
if (sequenceTimeoutMs && sequenceTimeoutMs > 0) {
shortcut$ = baseKeydownStream$.pipe(scan((acc, event) => {
let { matchedEvents, lastEventTime } = acc;
const currentTime = performance.now();
if (acc.emitState === EmitStates.Emit) {
matchedEvents = [];
lastEventTime = 0;
}
if (matchedEvents.length > 0 && (currentTime - lastEventTime > sequenceTimeoutMs)) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) attempt timed out. Matched: ${matchedEvents.map(e => e.key).join(",")}. Resetting.`);
}
matchedEvents = [];
}
const nextExpectedKeyIndex = matchedEvents.length;
if (nextExpectedKeyIndex >= sequenceLength) {
// Sequence was already emitted or buffer is too long (should not happen if reset correctly)
// Start new sequence if current key matches the first key of the sequence
if (sequenceLength > 0 && compareKey(event.key, configuredSequence[0])) {
return { matchedEvents: [event], lastEventTime: currentTime, emitState: EmitStates.InProgress };
}
return { matchedEvents: [], lastEventTime: 0, emitState: EmitStates.Ignore };
}
if (compareKey(event.key, configuredSequence[nextExpectedKeyIndex])) {
const newMatchedEvents = [...matchedEvents, event];
if (newMatchedEvents.length === sequenceLength) {
if (this.debugMode && acc.emitState !== EmitStates.Emit)
console.log(`${Hotkeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) matched.`);
return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: EmitStates.Emit };
}
else {
return { matchedEvents: newMatchedEvents, lastEventTime: currentTime, emitState: EmitStates.InProgress };
}
}
else {
// If current key breaks sequence, check if it starts a new sequence
if (matchedEvents.length > 0 && this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Sequence "${id}" (timeout: ${sequenceTimeoutMs}ms) broken by key "${event.key}". Matched: ${matchedEvents.map(e => e.key).join(",")}. Resetting.`);
}
if (sequenceLength > 0 && compareKey(event.key, configuredSequence[0])) {
return { matchedEvents: [event], lastEventTime: currentTime, emitState: EmitStates.InProgress };
}
else {
return { matchedEvents: [], lastEventTime: 0, emitState: EmitStates.Ignore };
}
}
}, { matchedEvents: [], lastEventTime: 0, emitState: EmitStates.Ignore }), filter(state => state.emitState === EmitStates.Emit), map(state => state.matchedEvents));
}
else {
// No timeout logic: simple buffer-based matching
shortcut$ = baseKeydownStream$.pipe(bufferCount(sequenceLength, 1), filter((events) => {
if (events.length < sequenceLength)
return false;
return events.every((event, index) => compareKey(event.key, configuredSequence[index]));
}));
}
const terminator$ = new Subject();
const finalShortcutWithPriority$ = shortcut$.pipe(withLatestFrom(this.activeContext$), filter(([_completedEvents, activeCtx]) => {
if (context != null || strict) { // This sequence is NOT global or strict
return true;
}
// This sequence IS global. Check for specific overrides.
if (activeCtx == null) { // No specific context active
return true;
}
for (const [, otherAS] of this.activeShortcuts) {
if (otherAS.config.id !== id &&
"sequence" in otherAS.config &&
otherAS.config.context === activeCtx &&
this._areSequencesIdentical(configuredSequence, typeof otherAS.config.sequence === "string" ? this._parseSequenceString(otherAS.config.sequence) : otherAS.config.sequence)) {
if (this.debugMode) {
console.log(`${Hotkeys.LOG_PREFIX} Global sequence shortcut "${id}" suppressed by identical specific-context shortcut "${otherAS.config.id}".`);
}
return false; // Suppress global
}
}
return true; // Global sequence can proceed
}), map(([events]) => events), tap((events) => {
if (this.debugMode) {
const timeoutInfo = (sequenceTimeoutMs && sequenceTimeoutMs > 0) ? ` (with timeout logic)` : ` (no timeout logic)`;
const preventAction = preventDefault ? ", preventing default for last event" : "";
console.log(`${Hotkeys.LOG_PREFIX} Sequence "${id}" triggered${timeoutInfo}${preventAction}.`);
}
if (preventDefault && events.length > 0) {
events[events.length - 1].preventDefault();
}
}), catchError(err => {
console.error(`${Hotkeys.LOG_PREFIX} Error in sequence stream for shortcut "${id}":`, err);
return EMPTY;
}));
const logDetails = `Sequence: ${configuredSequence.join(" -> ")}${sequenceTimeoutMs && sequenceTimeoutMs > 0 ? ` (timeout: ${sequenceTimeoutMs}ms)` : ""}`;
this._registerShortcut(config, terminator$, ShortcutTypes.Sequence, logDetails);
return finalShortcutWithPriority$.pipe(map((events) => events[events.length - 1]), takeUntil(terminator$));
}
/**
* Removes a registered shortcut by its ID.
* This will complete the corresponding Observable stream for any subscribers.
* @param id - The unique ID of the shortcut to remove.
* @returns True if the shortcut was found and removed, false otherwise.
* A warning is logged to the console if no shortcut with the given ID is found.
*/
remove(id) {
const shortcut = this.activeShortcuts.get(id);
if (shortcut) {
shortcut.terminator$.next();
shortcut.terminator$.complete();
this.activeShortcuts.delete(id);
if (this.debugMode)
console.log(`${Hotkeys.LOG_PREFIX} Shortcut "${id}" removed.`);
return true;
}
console.warn(`${Hotkeys.LOG_PREFIX} Shortcut with ID "${id}" not found for removal.`);
return false;
}
/**
* Retrieves a list of all currently active (registered) shortcut configurations.
* This can be useful for displaying available shortcuts to the user or for debugging.
* @returns An array of objects, where each object represents an active shortcut
* and includes its `id`, `description` (if provided), `context` (if any),
* and `type` (from `ShortcutTypes` enum).
*/
getActiveShortcuts() {
const shortcuts = [];
for (const [id, activeShortcut] of this.activeShortcuts.entries()) {
shortcuts.push({
id,
description: activeShortcut.config.description,
context: activeShortcut.config.context,
type: ("sequence" in activeShortcut.config) ? ShortcutTypes.Sequence : ShortcutTypes.Combination
});
}
return shortcuts;
}
/**
* Cleans up all active subscriptions and resources used by the Hotkeys instance.
* This method should be called when the Hotkeys instance is no longer needed
* (e.g., when a component unmounts or the application is shutting down) to prevent memory leaks.
* After calling `destroy()`, the instance should not be used further.
*/
destroy() {
if (this.debugMode)
console.log(`${Hotkeys.LOG_PREFIX} Destroying library instance and terminating all shortcut streams.`);
this.activeShortcuts.forEach(shortcut => {
shortcut.terminator$.next();
shortcut.terminator$.complete();
});
this.activeShortcuts.clear();
this.contextStack$.complete();
if (this.debugMode)
console.log(`${Hotkeys.LOG_PREFIX} Library destroyed.`);
}
}
//# sourceMappingURL=hotkeys.js.map