Skip to content

Commit 2bb3e7c

Browse files
authored
Merge branch 'main' of https://github.com/github/hotkey into fix-pages
2 parents 2884cd2 + 8071994 commit 2bb3e7c

6 files changed

Lines changed: 115 additions & 97 deletions

File tree

pages/hotkey_mapper.html

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,12 @@
1212
<body>
1313
<div class="mx-auto my-3 col-12 col-md-8 col-lg-6">
1414
<h1 id="app-name">Hotkey Code</h1>
15-
<p id="hint">Press a key combination to see the corresponding hotkey string. Quickly press another combination to build a sequence.</p>
15+
<p id="hint">Press a key combination to see the corresponding hotkey string. Quickly press another combination to
16+
build a sequence.</p>
1617
<div class="position-relative">
17-
<input
18-
readonly
19-
role="application"
20-
aria-roledescription="Input Capture"
21-
autofocus
22-
aria-labelledby="app-name"
23-
aria-describedby="hint sequence-hint"
24-
aria-live="assertive"
25-
aria-atomic="true"
26-
id="hotkey-code"
27-
class="border rounded-2 mt-2 p-6 f1 text-mono"
28-
style="width: 100%"
29-
/>
18+
<input readonly role="application" aria-roledescription="Input Capture" autofocus aria-labelledby="app-name"
19+
aria-describedby="hint sequence-hint" aria-live="assertive" aria-atomic="true" id="hotkey-code"
20+
class="border rounded-2 mt-2 p-6 f1 text-mono" style="width: 100%" />
3021

3122
<div class="position-absolute bottom-2 left-3 right-3 d-flex" style="align-items: center; gap: 8px">
3223
<!-- This indicates that the input is listening for a sequence press. Ideally we'd have a way to tell screen
@@ -47,7 +38,7 @@ <h1 id="app-name">Hotkey Code</h1>
4738

4839
<script type="module">
4940
import {eventToHotkeyString} from './hotkey/index.js'
50-
import SequenceTracker from './hotkey/sequence.js'
41+
import {SEQUENCE_DELIMITER, SequenceTracker} from './hotkey/sequence.js'
5142

5243
const hotkeyCodeElement = document.getElementById('hotkey-code')
5344
const sequenceStatusElement = document.getElementById('sequence-status')
@@ -62,14 +53,14 @@ <h1 id="app-name">Hotkey Code</h1>
6253
let currentsequence = null
6354

6455
hotkeyCodeElement.addEventListener('keydown', event => {
65-
if (event.key === "Tab")
56+
if (event.key === "Tab")
6657
return;
6758

6859
event.preventDefault();
6960
event.stopPropagation();
7061

7162
currentsequence = eventToHotkeyString(event)
72-
event.currentTarget.value = [...sequenceTracker.path, currentsequence].join(' ');
63+
event.currentTarget.value = [...sequenceTracker.path, currentsequence].join(SEQUENCE_DELIMITER);
7364
})
7465

7566
hotkeyCodeElement.addEventListener('keyup', () => {
@@ -89,4 +80,4 @@ <h1 id="app-name">Hotkey Code</h1>
8980
</script>
9081
</body>
9182

92-
</html>
83+
</html>

src/hotkey.ts

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
1-
// # Returns a hotkey character string for keydown and keyup events.
2-
//
3-
// A full list of key names can be found here:
4-
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
5-
//
6-
// ## Code Example
7-
//
8-
// ```
9-
// document.addEventListener('keydown', function(event) {
10-
// if (hotkey(event) === 'h') ...
11-
// })
12-
// ```
13-
// ## Hotkey examples
14-
//
15-
// "s" // Lowercase character for single letters
16-
// "S" // Uppercase character for shift plus a letter
17-
// "1" // Number character
18-
// "?" // Shift plus "/" symbol
19-
//
20-
// "Enter" // Enter key
21-
// "ArrowUp" // Up arrow
22-
//
23-
// "Control+s" // Control modifier plus letter
24-
// "Control+Alt+Delete" // Multiple modifiers
25-
//
26-
// Returns key character String or null.
27-
export default function hotkey(event: KeyboardEvent): string {
1+
const normalizedHotkeyBrand = Symbol('normalizedHotkey')
2+
3+
/**
4+
* A hotkey string with modifier keys in standard order. Build one with `eventToHotkeyString` or normalize a string via
5+
* `normalizeHotkey`.
6+
*
7+
* A full list of key names can be found here:
8+
* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
9+
*
10+
* Examples:
11+
* "s" // Lowercase character for single letters
12+
* "S" // Uppercase character for shift plus a letter
13+
* "1" // Number character
14+
* "?" // Shift plus "/" symbol
15+
* "Enter" // Enter key
16+
* "ArrowUp" // Up arrow
17+
* "Control+s" // Control modifier plus letter
18+
* "Control+Alt+Delete" // Multiple modifiers
19+
*/
20+
export type NormalizedHotkeyString = string & {[normalizedHotkeyBrand]: true}
21+
22+
/**
23+
* Returns a hotkey character string for keydown and keyup events.
24+
* @example
25+
* document.addEventListener('keydown', function(event) {
26+
* if (eventToHotkeyString(event) === 'h') ...
27+
* })
28+
*/
29+
export function eventToHotkeyString(event: KeyboardEvent): NormalizedHotkeyString {
2830
const {ctrlKey, altKey, metaKey, key} = event
2931
const hotkeyString: string[] = []
3032
const modifiers: boolean[] = [ctrlKey, altKey, metaKey, showShift(event)]
@@ -37,13 +39,49 @@ export default function hotkey(event: KeyboardEvent): string {
3739
hotkeyString.push(key)
3840
}
3941

40-
return hotkeyString.join('+')
42+
return hotkeyString.join('+') as NormalizedHotkeyString
4143
}
4244

43-
const modifierKeyNames: string[] = [`Control`, 'Alt', 'Meta', 'Shift']
45+
const modifierKeyNames: string[] = ['Control', 'Alt', 'Meta', 'Shift']
4446

4547
// We don't want to show `Shift` when `event.key` is capital
4648
function showShift(event: KeyboardEvent): boolean {
4749
const {shiftKey, code, key} = event
4850
return shiftKey && !(code.startsWith('Key') && key.toUpperCase() === key)
4951
}
52+
53+
/**
54+
* Normalizes a hotkey string before comparing it to the serialized event
55+
* string produced by `eventToHotkeyString`.
56+
* - Replaces the `Mod` modifier with `Meta` on mac, `Control` on other
57+
* platforms.
58+
* - Ensures modifiers are sorted in a consistent order
59+
* @param hotkey a hotkey string
60+
* @param platform NOTE: this param is only intended to be used to mock `navigator.platform` in tests
61+
* @returns {string} normalized representation of the given hotkey string
62+
*/
63+
export function normalizeHotkey(hotkey: string, platform?: string | undefined): NormalizedHotkeyString {
64+
let result: string
65+
result = localizeMod(hotkey, platform)
66+
result = sortModifiers(result)
67+
return result as NormalizedHotkeyString
68+
}
69+
70+
const matchApplePlatform = /Mac|iPod|iPhone|iPad/i
71+
72+
function localizeMod(hotkey: string, platform: string = navigator.platform): string {
73+
const localModifier = matchApplePlatform.test(platform) ? 'Meta' : 'Control'
74+
return hotkey.replace('Mod', localModifier)
75+
}
76+
77+
function sortModifiers(hotkey: string): string {
78+
const key = hotkey.split('+').pop()
79+
const modifiers = []
80+
for (const modifier of ['Control', 'Alt', 'Meta', 'Shift']) {
81+
if (hotkey.includes(modifier)) {
82+
modifiers.push(modifier)
83+
}
84+
}
85+
modifiers.push(key)
86+
return modifiers.join('+')
87+
}

src/index.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {Leaf, RadixTrie} from './radix-trie'
22
import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils'
3-
import eventToHotkeyString from './hotkey'
4-
import SequenceTracker from './sequence'
3+
import {SequenceTracker} from './sequence'
4+
import {eventToHotkeyString} from './hotkey'
55

6-
export * from './normalize-hotkey'
6+
export {eventToHotkeyString, normalizeHotkey, NormalizedHotkeyString} from './hotkey'
7+
export {SequenceTracker, normalizeSequence, NormalizedSequenceString} from './sequence'
8+
export {RadixTrie, Leaf} from './radix-trie'
79

810
const hotkeyRadixTrie = new RadixTrie<HTMLElement>()
911
const elementsLeaves = new WeakMap<HTMLElement, Array<Leaf<HTMLElement>>>()
@@ -31,7 +33,7 @@ function keyDownHandler(event: KeyboardEvent) {
3133
sequenceTracker.reset()
3234
return
3335
}
34-
sequenceTracker.registerKeypress(eventToHotkeyString(event))
36+
sequenceTracker.registerKeypress(event)
3537

3638
currentTriePosition = newTriePosition
3739
if (newTriePosition instanceof Leaf) {
@@ -58,8 +60,6 @@ function keyDownHandler(event: KeyboardEvent) {
5860
}
5961
}
6062

61-
export {RadixTrie, Leaf, eventToHotkeyString}
62-
6363
export function install(element: HTMLElement, hotkey?: string): void {
6464
// Install the keydown handler if this is the first install
6565
if (Object.keys(hotkeyRadixTrie.children).length === 0) {

src/normalize-hotkey.ts

Lines changed: 0 additions & 35 deletions
This file was deleted.

src/sequence.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,40 @@
1+
import {NormalizedHotkeyString, eventToHotkeyString, normalizeHotkey} from './hotkey'
2+
13
interface SequenceTrackerOptions {
24
onReset?: () => void
35
}
46

5-
export default class SequenceTracker {
7+
export const SEQUENCE_DELIMITER = ' '
8+
9+
const sequenceBrand = Symbol('sequence')
10+
11+
/**
12+
* Sequence of hotkeys, separated by spaces. For example, `Mod+m g`. Obtain one through the `SequenceTracker` class or
13+
* by normalizing a string with `normalizeSequence`.
14+
*/
15+
export type NormalizedSequenceString = string & {[sequenceBrand]: true}
16+
17+
export class SequenceTracker {
618
static readonly CHORD_TIMEOUT = 1500
719

8-
private _path: readonly string[] = []
20+
private _path: readonly NormalizedHotkeyString[] = []
921
private timer: number | null = null
1022
private onReset
1123

1224
constructor({onReset}: SequenceTrackerOptions = {}) {
1325
this.onReset = onReset
1426
}
1527

16-
get path(): readonly string[] {
28+
get path(): readonly NormalizedHotkeyString[] {
1729
return this._path
1830
}
1931

20-
registerKeypress(hotkey: string): void {
21-
this._path = [...this._path, hotkey]
32+
get sequence(): NormalizedSequenceString {
33+
return this._path.join(SEQUENCE_DELIMITER) as NormalizedSequenceString
34+
}
35+
36+
registerKeypress(event: KeyboardEvent): void {
37+
this._path = [...this._path, eventToHotkeyString(event)]
2238
this.startTimer()
2339
}
2440

@@ -40,3 +56,10 @@ export default class SequenceTracker {
4056
this.timer = window.setTimeout(() => this.reset(), SequenceTracker.CHORD_TIMEOUT)
4157
}
4258
}
59+
60+
export function normalizeSequence(sequence: string): NormalizedSequenceString {
61+
return sequence
62+
.split(SEQUENCE_DELIMITER)
63+
.map(h => normalizeHotkey(h))
64+
.join(SEQUENCE_DELIMITER) as NormalizedSequenceString
65+
}

src/utils.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {normalizeHotkey} from './normalize-hotkey'
1+
import {NormalizedHotkeyString, normalizeHotkey} from './hotkey'
2+
import {SEQUENCE_DELIMITER} from './sequence'
23

34
export function isFormField(element: Node): boolean {
45
if (!(element instanceof HTMLElement)) {
@@ -20,7 +21,7 @@ export function isFormField(element: Node): boolean {
2021
)
2122
}
2223

23-
export function fireDeterminedAction(el: HTMLElement, path: readonly string[]): void {
24+
export function fireDeterminedAction(el: HTMLElement, path: readonly NormalizedHotkeyString[]): void {
2425
const delegateEvent = new CustomEvent('hotkey-fire', {cancelable: true, detail: {path}})
2526
const cancelled = !el.dispatchEvent(delegateEvent)
2627
if (cancelled) return
@@ -31,7 +32,7 @@ export function fireDeterminedAction(el: HTMLElement, path: readonly string[]):
3132
}
3233
}
3334

34-
export function expandHotkeyToEdges(hotkey: string): string[][] {
35+
export function expandHotkeyToEdges(hotkey: string): NormalizedHotkeyString[][] {
3536
// NOTE: we can't just split by comma, since comma is a valid hotkey character!
3637
const output = []
3738
let acc = ['']
@@ -44,7 +45,7 @@ export function expandHotkeyToEdges(hotkey: string): string[][] {
4445
continue
4546
}
4647

47-
if (hotkey[i] === ' ') {
48+
if (hotkey[i] === SEQUENCE_DELIMITER) {
4849
// Spaces are used to separate key sequences, so a following comma is
4950
// part of the sequence, not a separator.
5051
acc.push('')

0 commit comments

Comments
 (0)