@tanstack/hotkeys 0.4.3 → 0.6.0
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.
- package/README.md +1 -1
- package/dist/constants.cjs +70 -10
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +57 -4
- package/dist/constants.d.ts +57 -4
- package/dist/constants.js +67 -10
- package/dist/constants.js.map +1 -1
- package/dist/format.cjs +20 -125
- package/dist/format.cjs.map +1 -1
- package/dist/format.d.cts +8 -72
- package/dist/format.d.ts +8 -72
- package/dist/format.js +23 -127
- package/dist/format.js.map +1 -1
- package/dist/hotkey-manager.cjs.map +1 -1
- package/dist/hotkey-manager.d.cts +5 -1
- package/dist/hotkey-manager.d.ts +5 -1
- package/dist/hotkey-manager.js.map +1 -1
- package/dist/{recorder.cjs → hotkey-recorder.cjs} +14 -16
- package/dist/hotkey-recorder.cjs.map +1 -0
- package/dist/{recorder.d.cts → hotkey-recorder.d.cts} +2 -2
- package/dist/{recorder.d.ts → hotkey-recorder.d.ts} +2 -2
- package/dist/{recorder.js → hotkey-recorder.js} +14 -16
- package/dist/hotkey-recorder.js.map +1 -0
- package/dist/hotkey-sequence-recorder.cjs +188 -0
- package/dist/hotkey-sequence-recorder.cjs.map +1 -0
- package/dist/hotkey-sequence-recorder.d.cts +71 -0
- package/dist/hotkey-sequence-recorder.d.ts +71 -0
- package/dist/hotkey-sequence-recorder.js +188 -0
- package/dist/hotkey-sequence-recorder.js.map +1 -0
- package/dist/hotkey.d.cts +2 -0
- package/dist/hotkey.d.ts +2 -0
- package/dist/index.cjs +12 -7
- package/dist/index.d.cts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/parse.cjs +53 -91
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.d.cts +31 -72
- package/dist/parse.d.ts +31 -72
- package/dist/parse.js +51 -89
- package/dist/parse.js.map +1 -1
- package/dist/recorder-chord.cjs +18 -0
- package/dist/recorder-chord.cjs.map +1 -0
- package/dist/recorder-chord.js +18 -0
- package/dist/recorder-chord.js.map +1 -0
- package/dist/sequence-manager.cjs +24 -8
- package/dist/sequence-manager.cjs.map +1 -1
- package/dist/sequence-manager.d.cts +11 -2
- package/dist/sequence-manager.d.ts +11 -2
- package/dist/sequence-manager.js +25 -10
- package/dist/sequence-manager.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +75 -21
- package/src/format.ts +58 -196
- package/src/hotkey-manager.ts +5 -1
- package/src/{recorder.ts → hotkey-recorder.ts} +18 -32
- package/src/hotkey-sequence-recorder.ts +308 -0
- package/src/hotkey.ts +2 -0
- package/src/index.ts +2 -1
- package/src/parse.ts +91 -131
- package/src/recorder-chord.ts +25 -0
- package/src/sequence-manager.ts +30 -9
- package/dist/recorder.cjs.map +0 -1
- package/dist/recorder.js.map +0 -1
|
@@ -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","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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"}
|
|
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","normalizeKeyName","isEventForTarget","isInputElement","#syncRegistrationView","matchesKeyboardEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform, normalizeKeyName } 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 * Exported for consumers that display sequence state (e.g. devtools).\n */\nexport const 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 * Progress fields reflect an in-progress match (between first key and completion or timeout).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n /** Steps matched in the current attempt (0 when idle or just completed). */\n matchedStepCount: number\n /** `Date.now()` when the last step in the current attempt matched; 0 if none. */\n partialMatchLastKeyTime: 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 const len = reg.parsedSequence.length\n const inProgress = reg.currentIndex > 0 && reg.currentIndex < len\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n matchedStepCount: inProgress ? reg.currentIndex : 0,\n partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0,\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 #syncRegistrationView(registration: SequenceRegistration): void {\n this.registrations.setState((prev) =>\n new Map(prev).set(registration.id, toRegistrationView(registration)),\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(normalizeKeyName(event.key))) {\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 this.#syncRegistrationView(registration)\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.triggerCount++\n registration.currentIndex = 0\n\n this.#syncRegistrationView(registration)\n\n registration.callback(event, context)\n } else {\n this.#syncRegistrationView(registration)\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 this.#syncRegistrationView(registration)\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 this.#syncRegistrationView(registration)\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.#syncRegistrationView(registration)\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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"}
|
|
@@ -29,9 +29,14 @@ interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
type HotkeySequence = Array<Hotkey>;
|
|
32
|
+
/**
|
|
33
|
+
* Default timeout between keys in a sequence (in milliseconds).
|
|
34
|
+
* Exported for consumers that display sequence state (e.g. devtools).
|
|
35
|
+
*/
|
|
36
|
+
declare const DEFAULT_SEQUENCE_TIMEOUT = 1000;
|
|
32
37
|
/**
|
|
33
38
|
* View of a sequence registration for devtools display.
|
|
34
|
-
*
|
|
39
|
+
* Progress fields reflect an in-progress match (between first key and completion or timeout).
|
|
35
40
|
*/
|
|
36
41
|
interface SequenceRegistrationView {
|
|
37
42
|
id: string;
|
|
@@ -39,6 +44,10 @@ interface SequenceRegistrationView {
|
|
|
39
44
|
options: SequenceOptions;
|
|
40
45
|
target: Target;
|
|
41
46
|
triggerCount: number;
|
|
47
|
+
/** Steps matched in the current attempt (0 when idle or just completed). */
|
|
48
|
+
matchedStepCount: number;
|
|
49
|
+
/** `Date.now()` when the last step in the current attempt matched; 0 if none. */
|
|
50
|
+
partialMatchLastKeyTime: number;
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
44
53
|
* A handle returned from SequenceManager.register() that allows updating
|
|
@@ -142,5 +151,5 @@ declare function createSequenceMatcher(sequence: HotkeySequence, options?: {
|
|
|
142
151
|
getProgress: () => number;
|
|
143
152
|
};
|
|
144
153
|
//#endregion
|
|
145
|
-
export { HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
|
|
154
|
+
export { DEFAULT_SEQUENCE_TIMEOUT, HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
|
|
146
155
|
//# sourceMappingURL=sequence-manager.d.cts.map
|
|
@@ -29,9 +29,14 @@ interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
type HotkeySequence = Array<Hotkey>;
|
|
32
|
+
/**
|
|
33
|
+
* Default timeout between keys in a sequence (in milliseconds).
|
|
34
|
+
* Exported for consumers that display sequence state (e.g. devtools).
|
|
35
|
+
*/
|
|
36
|
+
declare const DEFAULT_SEQUENCE_TIMEOUT = 1000;
|
|
32
37
|
/**
|
|
33
38
|
* View of a sequence registration for devtools display.
|
|
34
|
-
*
|
|
39
|
+
* Progress fields reflect an in-progress match (between first key and completion or timeout).
|
|
35
40
|
*/
|
|
36
41
|
interface SequenceRegistrationView {
|
|
37
42
|
id: string;
|
|
@@ -39,6 +44,10 @@ interface SequenceRegistrationView {
|
|
|
39
44
|
options: SequenceOptions;
|
|
40
45
|
target: Target;
|
|
41
46
|
triggerCount: number;
|
|
47
|
+
/** Steps matched in the current attempt (0 when idle or just completed). */
|
|
48
|
+
matchedStepCount: number;
|
|
49
|
+
/** `Date.now()` when the last step in the current attempt matched; 0 if none. */
|
|
50
|
+
partialMatchLastKeyTime: number;
|
|
42
51
|
}
|
|
43
52
|
/**
|
|
44
53
|
* A handle returned from SequenceManager.register() that allows updating
|
|
@@ -142,5 +151,5 @@ declare function createSequenceMatcher(sequence: HotkeySequence, options?: {
|
|
|
142
151
|
getProgress: () => number;
|
|
143
152
|
};
|
|
144
153
|
//#endregion
|
|
145
|
-
export { HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
|
|
154
|
+
export { DEFAULT_SEQUENCE_TIMEOUT, HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
|
|
146
155
|
//# sourceMappingURL=sequence-manager.d.ts.map
|
package/dist/sequence-manager.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectPlatform } from "./constants.js";
|
|
1
|
+
import { detectPlatform, normalizeKeyName } from "./constants.js";
|
|
2
2
|
import { isModifierKey, parseHotkey } from "./parse.js";
|
|
3
3
|
import { formatHotkeySequence } from "./format.js";
|
|
4
4
|
import { matchesKeyboardEvent } from "./match.js";
|
|
@@ -8,6 +8,7 @@ import { Store } from "@tanstack/store";
|
|
|
8
8
|
//#region src/sequence-manager.ts
|
|
9
9
|
/**
|
|
10
10
|
* Default timeout between keys in a sequence (in milliseconds).
|
|
11
|
+
* Exported for consumers that display sequence state (e.g. devtools).
|
|
11
12
|
*/
|
|
12
13
|
const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
|
|
13
14
|
let sequenceIdCounter = 0;
|
|
@@ -52,12 +53,16 @@ function sequenceKey(sequence) {
|
|
|
52
53
|
* Builds a devtools view from an internal registration.
|
|
53
54
|
*/
|
|
54
55
|
function toRegistrationView(reg) {
|
|
56
|
+
const len = reg.parsedSequence.length;
|
|
57
|
+
const inProgress = reg.currentIndex > 0 && reg.currentIndex < len;
|
|
55
58
|
return {
|
|
56
59
|
id: reg.id,
|
|
57
60
|
sequence: reg.sequence,
|
|
58
61
|
options: reg.options,
|
|
59
62
|
target: reg.target,
|
|
60
|
-
triggerCount: reg.triggerCount
|
|
63
|
+
triggerCount: reg.triggerCount,
|
|
64
|
+
matchedStepCount: inProgress ? reg.currentIndex : 0,
|
|
65
|
+
partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0
|
|
61
66
|
};
|
|
62
67
|
}
|
|
63
68
|
var SequenceManager = class SequenceManager {
|
|
@@ -86,6 +91,9 @@ var SequenceManager = class SequenceManager {
|
|
|
86
91
|
SequenceManager.#instance = null;
|
|
87
92
|
}
|
|
88
93
|
}
|
|
94
|
+
#syncRegistrationView(registration) {
|
|
95
|
+
this.registrations.setState((prev) => new Map(prev).set(registration.id, toRegistrationView(registration)));
|
|
96
|
+
}
|
|
89
97
|
/**
|
|
90
98
|
* Registers a hotkey sequence handler.
|
|
91
99
|
*
|
|
@@ -223,7 +231,7 @@ var SequenceManager = class SequenceManager {
|
|
|
223
231
|
* Processes keyboard events for a specific target and event type.
|
|
224
232
|
*/
|
|
225
233
|
#processTargetEvent(event, target, eventType) {
|
|
226
|
-
if (isModifierKey(event)) return;
|
|
234
|
+
if (isModifierKey(normalizeKeyName(event.key))) return;
|
|
227
235
|
const targetRegs = this.#targetRegistrations.get(target);
|
|
228
236
|
if (!targetRegs) return;
|
|
229
237
|
const now = Date.now();
|
|
@@ -238,8 +246,11 @@ var SequenceManager = class SequenceManager {
|
|
|
238
246
|
}
|
|
239
247
|
}
|
|
240
248
|
if (registration.options.eventType !== eventType) continue;
|
|
241
|
-
const timeout = registration.options.timeout ??
|
|
242
|
-
if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout)
|
|
249
|
+
const timeout = registration.options.timeout ?? 1e3;
|
|
250
|
+
if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
|
|
251
|
+
registration.currentIndex = 0;
|
|
252
|
+
this.#syncRegistrationView(registration);
|
|
253
|
+
}
|
|
243
254
|
const expectedHotkey = registration.parsedSequence[registration.currentIndex];
|
|
244
255
|
if (!expectedHotkey) continue;
|
|
245
256
|
if (matchesKeyboardEvent(event, expectedHotkey, registration.options.platform)) {
|
|
@@ -252,15 +263,18 @@ var SequenceManager = class SequenceManager {
|
|
|
252
263
|
hotkey: registration.sequence.join(" "),
|
|
253
264
|
parsedHotkey: registration.parsedSequence[registration.parsedSequence.length - 1]
|
|
254
265
|
};
|
|
255
|
-
registration.
|
|
266
|
+
registration.triggerCount++;
|
|
256
267
|
registration.currentIndex = 0;
|
|
257
|
-
|
|
268
|
+
this.#syncRegistrationView(registration);
|
|
269
|
+
registration.callback(event, context);
|
|
270
|
+
} else this.#syncRegistrationView(registration);
|
|
258
271
|
} else if (registration.currentIndex > 0) {
|
|
259
272
|
const firstHotkey = registration.parsedSequence[0];
|
|
260
273
|
if (matchesKeyboardEvent(event, firstHotkey, registration.options.platform)) {
|
|
261
274
|
registration.currentIndex = 1;
|
|
262
275
|
registration.lastKeyTime = now;
|
|
263
276
|
} else registration.currentIndex = 0;
|
|
277
|
+
this.#syncRegistrationView(registration);
|
|
264
278
|
}
|
|
265
279
|
}
|
|
266
280
|
}
|
|
@@ -279,6 +293,7 @@ var SequenceManager = class SequenceManager {
|
|
|
279
293
|
for (const registration of this.#registrations.values()) {
|
|
280
294
|
registration.currentIndex = 0;
|
|
281
295
|
registration.lastKeyTime = 0;
|
|
296
|
+
this.#syncRegistrationView(registration);
|
|
282
297
|
}
|
|
283
298
|
}
|
|
284
299
|
/**
|
|
@@ -303,7 +318,7 @@ var SequenceManager = class SequenceManager {
|
|
|
303
318
|
cancelable: true
|
|
304
319
|
});
|
|
305
320
|
registration.triggerCount++;
|
|
306
|
-
this
|
|
321
|
+
this.#syncRegistrationView(registration);
|
|
307
322
|
const context = {
|
|
308
323
|
hotkey: registration.sequence.join(" "),
|
|
309
324
|
parsedHotkey: lastParsed
|
|
@@ -357,7 +372,7 @@ function getSequenceManager() {
|
|
|
357
372
|
*/
|
|
358
373
|
function createSequenceMatcher(sequence, options = {}) {
|
|
359
374
|
const platform = options.platform ?? detectPlatform();
|
|
360
|
-
const timeout = options.timeout ??
|
|
375
|
+
const timeout = options.timeout ?? 1e3;
|
|
361
376
|
const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform));
|
|
362
377
|
let currentIndex = 0;
|
|
363
378
|
let lastKeyTime = 0;
|
|
@@ -391,5 +406,5 @@ function createSequenceMatcher(sequence, options = {}) {
|
|
|
391
406
|
}
|
|
392
407
|
|
|
393
408
|
//#endregion
|
|
394
|
-
export { SequenceManager, createSequenceMatcher, getSequenceManager };
|
|
409
|
+
export { DEFAULT_SEQUENCE_TIMEOUT, SequenceManager, createSequenceMatcher, getSequenceManager };
|
|
395
410
|
//# sourceMappingURL=sequence-manager.js.map
|
|
@@ -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 { 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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"}
|
|
1
|
+
{"version":3,"file":"sequence-manager.js","names":["#instance","#platform","#findConflictingSequence","#unregister","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent","#syncRegistrationView"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform, normalizeKeyName } 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 * Exported for consumers that display sequence state (e.g. devtools).\n */\nexport const 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 * Progress fields reflect an in-progress match (between first key and completion or timeout).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n /** Steps matched in the current attempt (0 when idle or just completed). */\n matchedStepCount: number\n /** `Date.now()` when the last step in the current attempt matched; 0 if none. */\n partialMatchLastKeyTime: 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 const len = reg.parsedSequence.length\n const inProgress = reg.currentIndex > 0 && reg.currentIndex < len\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n matchedStepCount: inProgress ? reg.currentIndex : 0,\n partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0,\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 #syncRegistrationView(registration: SequenceRegistration): void {\n this.registrations.setState((prev) =>\n new Map(prev).set(registration.id, toRegistrationView(registration)),\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(normalizeKeyName(event.key))) {\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 this.#syncRegistrationView(registration)\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.triggerCount++\n registration.currentIndex = 0\n\n this.#syncRegistrationView(registration)\n\n registration.callback(event, context)\n } else {\n this.#syncRegistrationView(registration)\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 this.#syncRegistrationView(registration)\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 this.#syncRegistrationView(registration)\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.#syncRegistrationView(registration)\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
package/src/constants.ts
CHANGED
|
@@ -8,10 +8,6 @@ import type {
|
|
|
8
8
|
PunctuationKey,
|
|
9
9
|
} from './hotkey'
|
|
10
10
|
|
|
11
|
-
// =============================================================================
|
|
12
|
-
// Platform Detection
|
|
13
|
-
// =============================================================================
|
|
14
|
-
|
|
15
11
|
/**
|
|
16
12
|
* Detects the current platform based on browser navigator properties.
|
|
17
13
|
*
|
|
@@ -46,10 +42,6 @@ export function detectPlatform(): 'mac' | 'windows' | 'linux' {
|
|
|
46
42
|
return 'linux'
|
|
47
43
|
}
|
|
48
44
|
|
|
49
|
-
// =============================================================================
|
|
50
|
-
// Modifier Aliases
|
|
51
|
-
// =============================================================================
|
|
52
|
-
|
|
53
45
|
/**
|
|
54
46
|
* Canonical order for modifiers in normalized hotkey strings.
|
|
55
47
|
*
|
|
@@ -124,6 +116,12 @@ export const MODIFIER_ALIASES: Record<string, CanonicalModifier | 'Mod'> = {
|
|
|
124
116
|
cmd: 'Meta',
|
|
125
117
|
meta: 'Meta',
|
|
126
118
|
|
|
119
|
+
// DOM `KeyboardEvent.key` spellings for the meta / Windows key
|
|
120
|
+
OS: 'Meta',
|
|
121
|
+
os: 'Meta',
|
|
122
|
+
Win: 'Meta',
|
|
123
|
+
win: 'Meta',
|
|
124
|
+
|
|
127
125
|
// Platform-adaptive (resolved at runtime)
|
|
128
126
|
CommandOrControl: 'Mod',
|
|
129
127
|
Mod: 'Mod',
|
|
@@ -162,10 +160,6 @@ export function resolveModifier(
|
|
|
162
160
|
return modifier
|
|
163
161
|
}
|
|
164
162
|
|
|
165
|
-
// =============================================================================
|
|
166
|
-
// Valid Keys
|
|
167
|
-
// =============================================================================
|
|
168
|
-
|
|
169
163
|
/**
|
|
170
164
|
* Set of all valid letter keys (A-Z).
|
|
171
165
|
*
|
|
@@ -311,16 +305,16 @@ export const PUNCTUATION_KEYS = new Set<PunctuationKey>([
|
|
|
311
305
|
* fallbacks for letters and digits.
|
|
312
306
|
*/
|
|
313
307
|
export const PUNCTUATION_CODE_MAP: Record<string, string> = {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
Slash: '/',
|
|
308
|
+
Backquote: '`',
|
|
309
|
+
Backslash: '\\',
|
|
317
310
|
BracketLeft: '[',
|
|
318
311
|
BracketRight: ']',
|
|
319
|
-
Backslash: '\\',
|
|
320
312
|
Comma: ',',
|
|
313
|
+
Equal: '=',
|
|
314
|
+
Minus: '-',
|
|
321
315
|
Period: '.',
|
|
322
|
-
Backquote: '`',
|
|
323
316
|
Semicolon: ';',
|
|
317
|
+
Slash: '/',
|
|
324
318
|
}
|
|
325
319
|
|
|
326
320
|
/**
|
|
@@ -446,6 +440,20 @@ export function isSingleLetterKey(key: string): boolean {
|
|
|
446
440
|
return /^\p{Letter}$/u.test(key)
|
|
447
441
|
}
|
|
448
442
|
|
|
443
|
+
/**
|
|
444
|
+
* Normalizes a key name to its canonical form.
|
|
445
|
+
*
|
|
446
|
+
* @param key - The key name to normalize (can be an alias, lowercase, etc.)
|
|
447
|
+
* @returns The canonical key name
|
|
448
|
+
*
|
|
449
|
+
* @example
|
|
450
|
+
* ```ts
|
|
451
|
+
* normalizeKeyName('esc') // 'Escape'
|
|
452
|
+
* normalizeKeyName('a') // 'A'
|
|
453
|
+
* normalizeKeyName('f1') // 'F1'
|
|
454
|
+
* normalizeKeyName('ArrowUp') // 'ArrowUp' (already canonical)
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
449
457
|
export function normalizeKeyName(key: string): string {
|
|
450
458
|
// Check aliases first
|
|
451
459
|
if (key in KEY_ALIASES) {
|
|
@@ -488,11 +496,35 @@ export function normalizeKeyName(key: string): string {
|
|
|
488
496
|
* MAC_MODIFIER_SYMBOLS['Shift'] // '⇧'
|
|
489
497
|
* ```
|
|
490
498
|
*/
|
|
491
|
-
export const MAC_MODIFIER_SYMBOLS: Record<CanonicalModifier, string> = {
|
|
499
|
+
export const MAC_MODIFIER_SYMBOLS: Record<CanonicalModifier | 'Mod', string> = {
|
|
492
500
|
Control: '⌃',
|
|
493
501
|
Alt: '⌥',
|
|
494
502
|
Shift: '⇧',
|
|
495
503
|
Meta: '⌘',
|
|
504
|
+
Mod: '⌘',
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Modifier key labels for macOS display.
|
|
509
|
+
*
|
|
510
|
+
* Used by formatting functions to display hotkeys with macOS-style text labels
|
|
511
|
+
* (e.g., 'Control' for Control, 'Option' for Alt, 'Cmd' for Meta) instead of symbols.
|
|
512
|
+
* This provides a familiar macOS look and feel in hotkey displays.
|
|
513
|
+
*
|
|
514
|
+
* @example
|
|
515
|
+
* ```ts
|
|
516
|
+
* MAC_MODIFIER_LABELS['Control'] // 'control'
|
|
517
|
+
* MAC_MODIFIER_LABELS['Alt'] // 'option'
|
|
518
|
+
* MAC_MODIFIER_LABELS['Shift'] // 'shift'
|
|
519
|
+
* MAC_MODIFIER_LABELS['Meta'] // 'cmd'
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
export const MAC_MODIFIER_LABELS: Record<CanonicalModifier | 'Mod', string> = {
|
|
523
|
+
Control: 'Control',
|
|
524
|
+
Alt: 'Option',
|
|
525
|
+
Shift: 'Shift',
|
|
526
|
+
Meta: 'Cmd',
|
|
527
|
+
Mod: 'Cmd',
|
|
496
528
|
}
|
|
497
529
|
|
|
498
530
|
/**
|
|
@@ -510,13 +542,35 @@ export const MAC_MODIFIER_SYMBOLS: Record<CanonicalModifier, string> = {
|
|
|
510
542
|
* STANDARD_MODIFIER_LABELS['Shift'] // 'Shift'
|
|
511
543
|
* ```
|
|
512
544
|
*/
|
|
513
|
-
export const
|
|
545
|
+
export const WINDOWS_MODIFIER_LABELS: Record<
|
|
546
|
+
CanonicalModifier | 'Mod',
|
|
547
|
+
string
|
|
548
|
+
> = {
|
|
514
549
|
Control: 'Ctrl',
|
|
515
550
|
Alt: 'Alt',
|
|
516
551
|
Shift: 'Shift',
|
|
517
552
|
Meta: 'Win',
|
|
553
|
+
Mod: 'Ctrl',
|
|
518
554
|
}
|
|
519
555
|
|
|
556
|
+
export const LINUX_MODIFIER_LABELS: Record<CanonicalModifier | 'Mod', string> =
|
|
557
|
+
{
|
|
558
|
+
...WINDOWS_MODIFIER_LABELS,
|
|
559
|
+
Meta: 'Super',
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export const PUNCTUATION_KEY_DISPLAY_LABELS = {
|
|
563
|
+
'`': 'Backquote',
|
|
564
|
+
'\\': 'Backslash',
|
|
565
|
+
'[': 'Left Bracket',
|
|
566
|
+
']': 'Right Bracket',
|
|
567
|
+
',': 'Comma',
|
|
568
|
+
'=': 'Equal',
|
|
569
|
+
'-': 'Minus',
|
|
570
|
+
'.': 'Period',
|
|
571
|
+
';': 'Semicolon',
|
|
572
|
+
} as const satisfies Record<string, string>
|
|
573
|
+
|
|
520
574
|
/**
|
|
521
575
|
* Special key symbols for display formatting.
|
|
522
576
|
*
|
|
@@ -532,7 +586,7 @@ export const STANDARD_MODIFIER_LABELS: Record<CanonicalModifier, string> = {
|
|
|
532
586
|
* KEY_DISPLAY_SYMBOLS['Space'] // '␣'
|
|
533
587
|
* ```
|
|
534
588
|
*/
|
|
535
|
-
export const KEY_DISPLAY_SYMBOLS
|
|
589
|
+
export const KEY_DISPLAY_SYMBOLS = {
|
|
536
590
|
ArrowUp: '↑',
|
|
537
591
|
ArrowDown: '↓',
|
|
538
592
|
ArrowLeft: '←',
|
|
@@ -543,4 +597,4 @@ export const KEY_DISPLAY_SYMBOLS: Record<string, string> = {
|
|
|
543
597
|
Delete: '⌦',
|
|
544
598
|
Tab: '⇥',
|
|
545
599
|
Space: '␣',
|
|
546
|
-
}
|
|
600
|
+
} as const satisfies Record<string, string>
|