@tanstack/hotkeys 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,11 @@ function sequenceKey(sequence) {
30
30
  * that trigger callbacks when the full sequence is pressed within
31
31
  * a configurable timeout.
32
32
  *
33
+ * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta
34
+ * alone) are not processed: they neither match a step nor reset progress.
35
+ * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when
36
+ * the user presses a modifier alone between steps.
37
+ *
33
38
  * @example
34
39
  * ```ts
35
40
  * const matcher = SequenceManager.getInstance()
@@ -218,6 +223,7 @@ var SequenceManager = class SequenceManager {
218
223
  * Processes keyboard events for a specific target and event type.
219
224
  */
220
225
  #processTargetEvent(event, target, eventType) {
226
+ if (require_parse.isModifierKey(event)) return;
221
227
  const targetRegs = this.#targetRegistrations.get(target);
222
228
  if (!targetRegs) return;
223
229
  const now = Date.now();
@@ -1 +1 @@
1
- {"version":3,"file":"sequence-manager.cjs","names":["#instance","Store","#platform","detectPlatform","parseHotkey","#findConflictingSequence","formatHotkeySequence","#unregister","getDefaultIgnoreInputs","defaultHotkeyOptions","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent","isEventForTarget","isInputElement","matchesKeyboardEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n */\nconst DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Excludes internal matching state (currentIndex, lastKeyTime).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.callback(event, context)\n\n registration.currentIndex = 0\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sequence-manager.cjs","names":["#instance","Store","#platform","detectPlatform","parseHotkey","#findConflictingSequence","formatHotkeySequence","#unregister","getDefaultIgnoreInputs","defaultHotkeyOptions","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent","isModifierKey","isEventForTarget","isInputElement","matchesKeyboardEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { isModifierKey, parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * Each element is one step (a `Hotkey` string). Steps may include modifiers;\n * the same modifier can appear on consecutive steps (e.g. `Shift+R` then\n * `Shift+T`). Modifier-only key events do not advance or reset matching—see\n * `SequenceManager`.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n */\nconst DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Excludes internal matching state (currentIndex, lastKeyTime).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta\n * alone) are not processed: they neither match a step nor reset progress.\n * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when\n * the user presses a modifier alone between steps.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.\n if (isModifierKey(event)) {\n return\n }\n\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.callback(event, context)\n\n registration.currentIndex = 0\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":"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"}
@@ -15,11 +15,17 @@ interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
15
15
  /**
16
16
  * A sequence of hotkeys for Vim-style shortcuts.
17
17
  *
18
+ * Each element is one step (a `Hotkey` string). Steps may include modifiers;
19
+ * the same modifier can appear on consecutive steps (e.g. `Shift+R` then
20
+ * `Shift+T`). Modifier-only key events do not advance or reset matching—see
21
+ * `SequenceManager`.
22
+ *
18
23
  * @example
19
24
  * ```ts
20
25
  * const gotoTop: HotkeySequence = ['G', 'G'] // gg
21
26
  * const deleteLine: HotkeySequence = ['D', 'D'] // dd
22
27
  * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
28
+ * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']
23
29
  * ```
24
30
  */
25
31
  type HotkeySequence = Array<Hotkey>;
@@ -15,11 +15,17 @@ interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
15
15
  /**
16
16
  * A sequence of hotkeys for Vim-style shortcuts.
17
17
  *
18
+ * Each element is one step (a `Hotkey` string). Steps may include modifiers;
19
+ * the same modifier can appear on consecutive steps (e.g. `Shift+R` then
20
+ * `Shift+T`). Modifier-only key events do not advance or reset matching—see
21
+ * `SequenceManager`.
22
+ *
18
23
  * @example
19
24
  * ```ts
20
25
  * const gotoTop: HotkeySequence = ['G', 'G'] // gg
21
26
  * const deleteLine: HotkeySequence = ['D', 'D'] // dd
22
27
  * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
28
+ * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']
23
29
  * ```
24
30
  */
25
31
  type HotkeySequence = Array<Hotkey>;
@@ -1,5 +1,5 @@
1
1
  import { detectPlatform } from "./constants.js";
2
- import { parseHotkey } from "./parse.js";
2
+ import { isModifierKey, parseHotkey } from "./parse.js";
3
3
  import { formatHotkeySequence } from "./format.js";
4
4
  import { matchesKeyboardEvent } from "./match.js";
5
5
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, isEventForTarget, isInputElement } from "./manager.utils.js";
@@ -30,6 +30,11 @@ function sequenceKey(sequence) {
30
30
  * that trigger callbacks when the full sequence is pressed within
31
31
  * a configurable timeout.
32
32
  *
33
+ * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta
34
+ * alone) are not processed: they neither match a step nor reset progress.
35
+ * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when
36
+ * the user presses a modifier alone between steps.
37
+ *
33
38
  * @example
34
39
  * ```ts
35
40
  * const matcher = SequenceManager.getInstance()
@@ -218,6 +223,7 @@ var SequenceManager = class SequenceManager {
218
223
  * Processes keyboard events for a specific target and event type.
219
224
  */
220
225
  #processTargetEvent(event, target, eventType) {
226
+ if (isModifierKey(event)) return;
221
227
  const targetRegs = this.#targetRegistrations.get(target);
222
228
  if (!targetRegs) return;
223
229
  const now = Date.now();
@@ -1 +1 @@
1
- {"version":3,"file":"sequence-manager.js","names":["#instance","#platform","#findConflictingSequence","#unregister","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n */\nconst DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Excludes internal matching state (currentIndex, lastKeyTime).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.callback(event, context)\n\n registration.currentIndex = 0\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sequence-manager.js","names":["#instance","#platform","#findConflictingSequence","#unregister","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { isModifierKey, parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * Each element is one step (a `Hotkey` string). Steps may include modifiers;\n * the same modifier can appear on consecutive steps (e.g. `Shift+R` then\n * `Shift+T`). Modifier-only key events do not advance or reset matching—see\n * `SequenceManager`.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n */\nconst DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Excludes internal matching state (currentIndex, lastKeyTime).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta\n * alone) are not processed: they neither match a step nor reset progress.\n * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when\n * the user presses a modifier alone between steps.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.\n if (isModifierKey(event)) {\n return\n }\n\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.callback(event, context)\n\n registration.currentIndex = 0\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { formatHotkeySequence } from './format'
3
3
  import { detectPlatform } from './constants'
4
- import { parseHotkey } from './parse'
4
+ import { isModifierKey, parseHotkey } from './parse'
5
5
  import { matchesKeyboardEvent } from './match'
6
6
  import {
7
7
  defaultHotkeyOptions,
@@ -32,11 +32,17 @@ export interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
32
32
  /**
33
33
  * A sequence of hotkeys for Vim-style shortcuts.
34
34
  *
35
+ * Each element is one step (a `Hotkey` string). Steps may include modifiers;
36
+ * the same modifier can appear on consecutive steps (e.g. `Shift+R` then
37
+ * `Shift+T`). Modifier-only key events do not advance or reset matching—see
38
+ * `SequenceManager`.
39
+ *
35
40
  * @example
36
41
  * ```ts
37
42
  * const gotoTop: HotkeySequence = ['G', 'G'] // gg
38
43
  * const deleteLine: HotkeySequence = ['D', 'D'] // dd
39
44
  * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
45
+ * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']
40
46
  * ```
41
47
  */
42
48
  export type HotkeySequence = Array<Hotkey>
@@ -117,6 +123,11 @@ export interface SequenceRegistrationHandle {
117
123
  * that trigger callbacks when the full sequence is pressed within
118
124
  * a configurable timeout.
119
125
  *
126
+ * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta
127
+ * alone) are not processed: they neither match a step nor reset progress.
128
+ * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when
129
+ * the user presses a modifier alone between steps.
130
+ *
120
131
  * @example
121
132
  * ```ts
122
133
  * const matcher = SequenceManager.getInstance()
@@ -407,6 +418,11 @@ export class SequenceManager {
407
418
  target: Target,
408
419
  eventType: 'keydown' | 'keyup',
409
420
  ): void {
421
+ // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.
422
+ if (isModifierKey(event)) {
423
+ return
424
+ }
425
+
410
426
  const targetRegs = this.#targetRegistrations.get(target)
411
427
  if (!targetRegs) {
412
428
  return