From 78b4bcfccc1697dc2604a9b73a7d08ff8d2fab63 Mon Sep 17 00:00:00 2001 From: Eric Manganaro Date: Sun, 7 Jun 2026 02:53:33 -0400 Subject: [PATCH] fix(hotkeys): support numpad plus shortcuts --- packages/hotkeys/src/constants.ts | 9 ++++++ packages/hotkeys/src/format.ts | 2 +- packages/hotkeys/src/hotkey.ts | 2 ++ packages/hotkeys/src/match.ts | 8 +++++- packages/hotkeys/src/parse.ts | 28 +++++++++++++++++-- packages/hotkeys/src/validate.ts | 22 +++++++++++++-- packages/hotkeys/tests/hotkey-manager.test.ts | 21 ++++++++++++++ packages/hotkeys/tests/match.test.ts | 25 +++++++++++++++++ packages/hotkeys/tests/parse.test.ts | 17 +++++++++++ packages/hotkeys/tests/validate.test.ts | 9 ++++++ 10 files changed, 135 insertions(+), 8 deletions(-) diff --git a/packages/hotkeys/src/constants.ts b/packages/hotkeys/src/constants.ts index f89628ed..8f8e58c9 100644 --- a/packages/hotkeys/src/constants.ts +++ b/packages/hotkeys/src/constants.ts @@ -283,10 +283,12 @@ export const EDITING_KEYS = new Set([ * so they're excluded from Shift-based hotkey combinations to avoid layout-dependent behavior. */ export const PUNCTUATION_KEYS = new Set([ + 'Plus', '/', '[', ']', '\\', + '+', '=', '-', ',', @@ -312,6 +314,7 @@ export const PUNCTUATION_CODE_MAP: Record = { Comma: ',', Equal: '=', Minus: '-', + NumpadAdd: '+', Period: '.', Semicolon: ';', Slash: '/', @@ -410,6 +413,10 @@ const KEY_ALIASES: Record = { PgDn: 'PageDown', pgup: 'PageUp', pgdn: 'PageDown', + + // Punctuation variants + Plus: '+', + plus: '+', } /** @@ -563,11 +570,13 @@ export const LINUX_MODIFIER_LABELS: Record = } export const PUNCTUATION_KEY_DISPLAY_LABELS = { + Plus: 'Plus', '`': 'Backquote', '\\': 'Backslash', '[': 'Left Bracket', ']': 'Right Bracket', ',': 'Comma', + '+': 'Plus', '=': 'Equal', '-': 'Minus', '.': 'Period', diff --git a/packages/hotkeys/src/format.ts b/packages/hotkeys/src/format.ts index 1ba03b90..241ac03d 100644 --- a/packages/hotkeys/src/format.ts +++ b/packages/hotkeys/src/format.ts @@ -61,7 +61,7 @@ export function formatHotkey(parsed: ParsedHotkey): string { } // Add the key - parts.push(parsed.key) + parts.push(parsed.key === '+' ? 'Plus' : parsed.key) return parts.join('+') } diff --git a/packages/hotkeys/src/hotkey.ts b/packages/hotkeys/src/hotkey.ts index d0a33269..6d67e783 100644 --- a/packages/hotkeys/src/hotkey.ts +++ b/packages/hotkeys/src/hotkey.ts @@ -115,10 +115,12 @@ export type EditingKey = * (layout-dependent, typically US keyboard layout). */ export type PunctuationKey = + | 'Plus' | '/' | '[' | ']' | '\\' + | '+' | '=' | '-' | ',' diff --git a/packages/hotkeys/src/match.ts b/packages/hotkeys/src/match.ts index 46b61f1d..d9d37429 100644 --- a/packages/hotkeys/src/match.ts +++ b/packages/hotkeys/src/match.ts @@ -93,9 +93,15 @@ export function matchesKeyboardEvent( // event.key is a non-letter special character. // Dead keys: Option+letter on macOS, international layouts produce event.key === 'Dead' // Single-char mismatches: Cmd+Option+T gives '†' instead of 'T', Shift+4 gives '$' + const hasPunctuationCode = Boolean( + event.code && event.code in PUNCTUATION_CODE_MAP, + ) + if ( event.code && - (eventKey === 'Dead' || (eventKey.length === 1 && hotkeyKey.length === 1)) + (eventKey === 'Dead' || + (eventKey.length === 1 && hotkeyKey.length === 1) || + hasPunctuationCode) ) { // fallback for letter keys (common with mac option + letter) if (event.code.startsWith('Key')) { diff --git a/packages/hotkeys/src/parse.ts b/packages/hotkeys/src/parse.ts index 875659a1..b0d59684 100644 --- a/packages/hotkeys/src/parse.ts +++ b/packages/hotkeys/src/parse.ts @@ -32,7 +32,7 @@ export function parseHotkey( hotkey: Hotkey | (string & {}), platform: 'mac' | 'windows' | 'linux' = detectPlatform(), ): ParsedHotkey { - const parts = hotkey.split('+') + const parts = splitHotkeyParts(hotkey) const modifiers: Set = new Set() let key = '' @@ -75,6 +75,28 @@ export function parseHotkey( } } +function splitHotkeyParts(hotkey: string): Array { + const parts = hotkey.split('+').map((part) => part.trim()) + + if (hotkey.endsWith('+') && parts.length > 1) { + parts.pop() + + if (parts.at(-1) === '') { + parts.pop() + } + + parts.push('+') + } + + return parts +} + +function hotkeyKeyToken(key: string): string { + const normalizedKey = normalizeKeyName(key) + + return normalizedKey === '+' ? 'Plus' : normalizedKey +} + /** * Converts a RawHotkey object to a ParsedHotkey. * Optional modifier booleans default to false; modifiers array is derived from them. @@ -129,7 +151,7 @@ export function rawHotkeyToParsedHotkey( } }) return { - key: raw.key, + key: normalizeKeyName(raw.key), ctrl, shift, alt, @@ -170,7 +192,7 @@ function normalizedHotkeyStringFromParsed( } } - parts.push(normalizeKeyName(parsed.key)) + parts.push(hotkeyKeyToken(parsed.key)) return parts.join('+') as Hotkey } diff --git a/packages/hotkeys/src/validate.ts b/packages/hotkeys/src/validate.ts index 4d6830b7..54627474 100644 --- a/packages/hotkeys/src/validate.ts +++ b/packages/hotkeys/src/validate.ts @@ -1,4 +1,4 @@ -import { ALL_KEYS, MODIFIER_ALIASES } from './constants' +import { ALL_KEYS, MODIFIER_ALIASES, normalizeKeyName } from './constants' import type { Hotkey, ValidationResult } from './hotkey' /** @@ -36,7 +36,7 @@ export function validateHotkey( } } - const parts = hotkey.split('+').map((p) => p.trim()) + const parts = splitHotkeyParts(hotkey) // Must have at least one part (the key) if (parts.length === 0 || parts.some((p) => p === '')) { @@ -61,7 +61,7 @@ export function validateHotkey( } // Check if key is known - const normalizedKey = normalizeKeyForValidation(keyPart) + const normalizedKey = normalizeKeyName(normalizeKeyForValidation(keyPart)) if (!isKnownKey(normalizedKey) && !isKnownKey(keyPart)) { warnings.push( `Unknown key: '${keyPart}'. This may still work but won't have type-safe autocomplete.`, @@ -75,6 +75,22 @@ export function validateHotkey( } } +function splitHotkeyParts(hotkey: string): Array { + const parts = hotkey.split('+').map((p) => p.trim()) + + if (hotkey.endsWith('+') && parts.length > 1) { + parts.pop() + + if (parts.at(-1) === '') { + parts.pop() + } + + parts.push('+') + } + + return parts +} + /** * Normalizes a key for validation checking. */ diff --git a/packages/hotkeys/tests/hotkey-manager.test.ts b/packages/hotkeys/tests/hotkey-manager.test.ts index 7bd0a257..a9307fa5 100644 --- a/packages/hotkeys/tests/hotkey-manager.test.ts +++ b/packages/hotkeys/tests/hotkey-manager.test.ts @@ -12,10 +12,12 @@ function createKeyboardEvent( shiftKey?: boolean altKey?: boolean metaKey?: boolean + code?: string } = {}, ): KeyboardEvent { return new KeyboardEvent(type, { key, + code: options.code, ctrlKey: options.ctrlKey ?? false, shiftKey: options.shiftKey ?? false, altKey: options.altKey ?? false, @@ -124,6 +126,25 @@ describe('HotkeyManager', () => { ) expect(callback).toHaveBeenCalledTimes(1) }) + + it('should register a RawHotkey plus key and match NumpadAdd', () => { + const manager = HotkeyManager.getInstance() + const callback = vi.fn() + + manager.register({ key: '+', mod: true }, callback, { + platform: 'windows', + }) + + expect(manager.getRegistrationCount()).toBe(1) + expect(manager.isRegistered('Control+Plus')).toBe(true) + document.dispatchEvent( + createKeyboardEvent('keydown', 'Unidentified', { + code: 'NumpadAdd', + ctrlKey: true, + }), + ) + expect(callback).toHaveBeenCalledTimes(1) + }) }) describe('HotkeyRegistrationHandle', () => { diff --git a/packages/hotkeys/tests/match.test.ts b/packages/hotkeys/tests/match.test.ts index bd28b4e0..5f67231f 100644 --- a/packages/hotkeys/tests/match.test.ts +++ b/packages/hotkeys/tests/match.test.ts @@ -5,6 +5,7 @@ import { matchesKeyboardEvent, } from '../src/match' import { Hotkey } from '../src' +import { rawHotkeyToParsedHotkey } from '../src/parse' /** * Helper to create a mock KeyboardEvent @@ -704,6 +705,30 @@ describe('matchesKeyboardEvent', () => { expect(matchesKeyboardEvent(event, 'Alt+-' as Hotkey)).toBe(false) }) + it('should match NumpadAdd for plus hotkeys', () => { + const event = createKeyboardEvent('Unidentified', { + ctrlKey: true, + code: 'NumpadAdd', + }) + + expect(matchesKeyboardEvent(event, 'Mod++' as Hotkey, 'windows')).toBe( + true, + ) + }) + + it('should match NumpadAdd for parsed raw plus hotkeys', () => { + const event = createKeyboardEvent('Unidentified', { + metaKey: true, + code: 'NumpadAdd', + }) + const parsedHotkey = rawHotkeyToParsedHotkey( + { key: '+', mod: true }, + 'mac', + ) + + expect(matchesKeyboardEvent(event, parsedHotkey, 'mac')).toBe(true) + }) + it('should match Ctrl+punctuation without needing fallback (non-macOS)', () => { const event = createKeyboardEvent('-', { ctrlKey: true, diff --git a/packages/hotkeys/tests/parse.test.ts b/packages/hotkeys/tests/parse.test.ts index 40b8c8e6..6219f49d 100644 --- a/packages/hotkeys/tests/parse.test.ts +++ b/packages/hotkeys/tests/parse.test.ts @@ -49,6 +49,18 @@ describe('parseHotkey', () => { expect(parseHotkey('ArrowDown').key).toBe('ArrowDown') }) + it('should parse plus keys from delimiter-based strings', () => { + expect(parseHotkey('+').key).toBe('+') + expect(parseHotkey('Mod++', 'windows')).toMatchObject({ + ctrl: true, + key: '+', + }) + expect(parseHotkey('Mod+Plus', 'mac')).toMatchObject({ + key: '+', + meta: true, + }) + }) + it('should handle key aliases', () => { expect(parseHotkey('Esc').key).toBe('Escape') expect(parseHotkey('Return').key).toBe('Enter') @@ -210,6 +222,8 @@ describe('normalizeHotkey', () => { expect(normalizeHotkey('Ctrl+Esc', 'mac')).toBe('Control+Escape') expect(normalizeHotkey('Ctrl+Esc', 'windows')).toBe('Mod+Escape') expect(normalizeHotkey('Mod+Return', 'mac')).toBe('Mod+Enter') + expect(normalizeHotkey('Mod++', 'windows')).toBe('Mod+Plus') + expect(normalizeHotkey('Mod+Plus', 'mac')).toBe('Mod+Plus') }) it('should normalize single keys', () => { @@ -456,6 +470,9 @@ describe('normalizeRegisterableHotkey', () => { expect( normalizeRegisterableHotkey({ key: 's', mod: true }, 'windows'), ).toBe('Mod+S') + expect( + normalizeRegisterableHotkey({ key: '+', mod: true }, 'windows'), + ).toBe('Mod+Plus') }) }) diff --git a/packages/hotkeys/tests/validate.test.ts b/packages/hotkeys/tests/validate.test.ts index dcd01001..8b54db28 100644 --- a/packages/hotkeys/tests/validate.test.ts +++ b/packages/hotkeys/tests/validate.test.ts @@ -26,6 +26,15 @@ describe('validateHotkey', () => { expect(validateHotkey('Esc').valid).toBe(true) expect(validateHotkey('Return').valid).toBe(true) expect(validateHotkey('Del').valid).toBe(true) + expect(validateHotkey('Mod+Plus').valid).toBe(true) + }) + + it('should validate plus keys written with a trailing plus delimiter', () => { + const result = validateHotkey('Mod++') + + expect(result.valid).toBe(true) + expect(result.errors).toHaveLength(0) + expect(result.warnings).toHaveLength(0) }) })