Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/hotkeys/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,12 @@ export const EDITING_KEYS = new Set<EditingKey>([
* so they're excluded from Shift-based hotkey combinations to avoid layout-dependent behavior.
*/
export const PUNCTUATION_KEYS = new Set<PunctuationKey>([
'Plus',
'/',
'[',
']',
'\\',
'+',
'=',
'-',
',',
Expand All @@ -312,6 +314,7 @@ export const PUNCTUATION_CODE_MAP: Record<string, string> = {
Comma: ',',
Equal: '=',
Minus: '-',
NumpadAdd: '+',
Period: '.',
Semicolon: ';',
Slash: '/',
Expand Down Expand Up @@ -410,6 +413,10 @@ const KEY_ALIASES: Record<string, string> = {
PgDn: 'PageDown',
pgup: 'PageUp',
pgdn: 'PageDown',

// Punctuation variants
Plus: '+',
plus: '+',
}

/**
Expand Down Expand Up @@ -563,11 +570,13 @@ export const LINUX_MODIFIER_LABELS: Record<CanonicalModifier | 'Mod', string> =
}

export const PUNCTUATION_KEY_DISPLAY_LABELS = {
Plus: 'Plus',
'`': 'Backquote',
'\\': 'Backslash',
'[': 'Left Bracket',
']': 'Right Bracket',
',': 'Comma',
'+': 'Plus',
'=': 'Equal',
'-': 'Minus',
'.': 'Period',
Expand Down
2 changes: 1 addition & 1 deletion packages/hotkeys/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('+')
}
Expand Down
2 changes: 2 additions & 0 deletions packages/hotkeys/src/hotkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ export type EditingKey =
* (layout-dependent, typically US keyboard layout).
*/
export type PunctuationKey =
| 'Plus'
| '/'
| '['
| ']'
| '\\'
| '+'
| '='
| '-'
| ','
Expand Down
8 changes: 7 additions & 1 deletion packages/hotkeys/src/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')) {
Expand Down
28 changes: 25 additions & 3 deletions packages/hotkeys/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CanonicalModifier> = new Set()
let key = ''

Expand Down Expand Up @@ -75,6 +75,28 @@ export function parseHotkey(
}
}

function splitHotkeyParts(hotkey: string): Array<string> {
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.
Expand Down Expand Up @@ -129,7 +151,7 @@ export function rawHotkeyToParsedHotkey(
}
})
return {
key: raw.key,
key: normalizeKeyName(raw.key),
ctrl,
shift,
alt,
Expand Down Expand Up @@ -170,7 +192,7 @@ function normalizedHotkeyStringFromParsed(
}
}

parts.push(normalizeKeyName(parsed.key))
parts.push(hotkeyKeyToken(parsed.key))
return parts.join('+') as Hotkey
}

Expand Down
22 changes: 19 additions & 3 deletions packages/hotkeys/src/validate.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -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 === '')) {
Expand All @@ -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.`,
Expand All @@ -75,6 +75,22 @@ export function validateHotkey(
}
}

function splitHotkeyParts(hotkey: string): Array<string> {
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.
*/
Expand Down
21 changes: 21 additions & 0 deletions packages/hotkeys/tests/hotkey-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/hotkeys/tests/match.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
matchesKeyboardEvent,
} from '../src/match'
import { Hotkey } from '../src'
import { rawHotkeyToParsedHotkey } from '../src/parse'

/**
* Helper to create a mock KeyboardEvent
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/hotkeys/tests/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})

Expand Down
9 changes: 9 additions & 0 deletions packages/hotkeys/tests/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down