Skip to content

Commit

Permalink
Merge pull request #94 from github/expose-utils
Browse files Browse the repository at this point in the history
Expose tools for building & normalizing hotkey & sequence strings
  • Loading branch information
iansan5653 committed Oct 10, 2023
2 parents 6297512 + 365110a commit 8071994
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 82 deletions.
6 changes: 3 additions & 3 deletions pages/hotkey_mapper.html
Expand Up @@ -47,13 +47,13 @@ <h1 id="app-name">Hotkey Code</h1>

<script type="module">
import {eventToHotkeyString} from '../dist/index.js'
import sequenceTracker from '../dist/sequence.js'
import {SEQUENCE_DELIMITER, SequenceTracker} from '../dist/sequence.js'

const hotkeyCodeElement = document.getElementById('hotkey-code')
const sequenceStatusElement = document.getElementById('sequence-status')
const resetButtonElement = document.getElementById('reset-button')

const sequenceTracker = new sequenceTracker({
const SequenceTracker = new SequenceTracker({
onReset() {
sequenceStatusElement.hidden = true
}
Expand All @@ -69,7 +69,7 @@ <h1 id="app-name">Hotkey Code</h1>
event.stopPropagation();

currentsequence = eventToHotkeyString(event)
event.currentTarget.value = [...sequenceTracker.path, currentsequence].join(' ');
event.currentTarget.value = [...sequenceTracker.path, currentsequence].join(SEQUENCE_DELIMITER);
})

hotkeyCodeElement.addEventListener('keyup', () => {
Expand Down
96 changes: 67 additions & 29 deletions src/hotkey.ts
@@ -1,30 +1,32 @@
// # Returns a hotkey character string for keydown and keyup events.
//
// A full list of key names can be found here:
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
//
// ## Code Example
//
// ```
// document.addEventListener('keydown', function(event) {
// if (hotkey(event) === 'h') ...
// })
// ```
// ## Hotkey examples
//
// "s" // Lowercase character for single letters
// "S" // Uppercase character for shift plus a letter
// "1" // Number character
// "?" // Shift plus "/" symbol
//
// "Enter" // Enter key
// "ArrowUp" // Up arrow
//
// "Control+s" // Control modifier plus letter
// "Control+Alt+Delete" // Multiple modifiers
//
// Returns key character String or null.
export default function hotkey(event: KeyboardEvent): string {
const normalizedHotkeyBrand = Symbol('normalizedHotkey')

/**
* A hotkey string with modifier keys in standard order. Build one with `eventToHotkeyString` or normalize a string via
* `normalizeHotkey`.
*
* A full list of key names can be found here:
* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values
*
* Examples:
* "s" // Lowercase character for single letters
* "S" // Uppercase character for shift plus a letter
* "1" // Number character
* "?" // Shift plus "/" symbol
* "Enter" // Enter key
* "ArrowUp" // Up arrow
* "Control+s" // Control modifier plus letter
* "Control+Alt+Delete" // Multiple modifiers
*/
export type NormalizedHotkeyString = string & {[normalizedHotkeyBrand]: true}

/**
* Returns a hotkey character string for keydown and keyup events.
* @example
* document.addEventListener('keydown', function(event) {
* if (eventToHotkeyString(event) === 'h') ...
* })
*/
export function eventToHotkeyString(event: KeyboardEvent): NormalizedHotkeyString {
const {ctrlKey, altKey, metaKey, key} = event
const hotkeyString: string[] = []
const modifiers: boolean[] = [ctrlKey, altKey, metaKey, showShift(event)]
Expand All @@ -37,13 +39,49 @@ export default function hotkey(event: KeyboardEvent): string {
hotkeyString.push(key)
}

return hotkeyString.join('+')
return hotkeyString.join('+') as NormalizedHotkeyString
}

const modifierKeyNames: string[] = [`Control`, 'Alt', 'Meta', 'Shift']
const modifierKeyNames: string[] = ['Control', 'Alt', 'Meta', 'Shift']

// We don't want to show `Shift` when `event.key` is capital
function showShift(event: KeyboardEvent): boolean {
const {shiftKey, code, key} = event
return shiftKey && !(code.startsWith('Key') && key.toUpperCase() === key)
}

/**
* Normalizes a hotkey string before comparing it to the serialized event
* string produced by `eventToHotkeyString`.
* - Replaces the `Mod` modifier with `Meta` on mac, `Control` on other
* platforms.
* - Ensures modifiers are sorted in a consistent order
* @param hotkey a hotkey string
* @param platform NOTE: this param is only intended to be used to mock `navigator.platform` in tests
* @returns {string} normalized representation of the given hotkey string
*/
export function normalizeHotkey(hotkey: string, platform?: string | undefined): NormalizedHotkeyString {
let result: string
result = localizeMod(hotkey, platform)
result = sortModifiers(result)
return result as NormalizedHotkeyString
}

const matchApplePlatform = /Mac|iPod|iPhone|iPad/i

function localizeMod(hotkey: string, platform: string = navigator.platform): string {
const localModifier = matchApplePlatform.test(platform) ? 'Meta' : 'Control'
return hotkey.replace('Mod', localModifier)
}

function sortModifiers(hotkey: string): string {
const key = hotkey.split('+').pop()
const modifiers = []
for (const modifier of ['Control', 'Alt', 'Meta', 'Shift']) {
if (hotkey.includes(modifier)) {
modifiers.push(modifier)
}
}
modifiers.push(key)
return modifiers.join('+')
}
12 changes: 6 additions & 6 deletions src/index.ts
@@ -1,9 +1,11 @@
import {Leaf, RadixTrie} from './radix-trie'
import {fireDeterminedAction, expandHotkeyToEdges, isFormField} from './utils'
import eventToHotkeyString from './hotkey'
import SequenceTracker from './sequence'
import {SequenceTracker} from './sequence'
import {eventToHotkeyString} from './hotkey'

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

const hotkeyRadixTrie = new RadixTrie<HTMLElement>()
const elementsLeaves = new WeakMap<HTMLElement, Array<Leaf<HTMLElement>>>()
Expand Down Expand Up @@ -31,7 +33,7 @@ function keyDownHandler(event: KeyboardEvent) {
sequenceTracker.reset()
return
}
sequenceTracker.registerKeypress(eventToHotkeyString(event))
sequenceTracker.registerKeypress(event)

currentTriePosition = newTriePosition
if (newTriePosition instanceof Leaf) {
Expand All @@ -58,8 +60,6 @@ function keyDownHandler(event: KeyboardEvent) {
}
}

export {RadixTrie, Leaf, eventToHotkeyString}

export function install(element: HTMLElement, hotkey?: string): void {
// Install the keydown handler if this is the first install
if (Object.keys(hotkeyRadixTrie.children).length === 0) {
Expand Down
35 changes: 0 additions & 35 deletions src/normalize-hotkey.ts

This file was deleted.

33 changes: 28 additions & 5 deletions src/sequence.ts
@@ -1,24 +1,40 @@
import {NormalizedHotkeyString, eventToHotkeyString, normalizeHotkey} from './hotkey'

interface SequenceTrackerOptions {
onReset?: () => void
}

export default class SequenceTracker {
export const SEQUENCE_DELIMITER = ' '

const sequenceBrand = Symbol('sequence')

/**
* Sequence of hotkeys, separated by spaces. For example, `Mod+m g`. Obtain one through the `SequenceTracker` class or
* by normalizing a string with `normalizeSequence`.
*/
export type NormalizedSequenceString = string & {[sequenceBrand]: true}

export class SequenceTracker {
static readonly CHORD_TIMEOUT = 1500

private _path: readonly string[] = []
private _path: readonly NormalizedHotkeyString[] = []
private timer: number | null = null
private onReset

constructor({onReset}: SequenceTrackerOptions = {}) {
this.onReset = onReset
}

get path(): readonly string[] {
get path(): readonly NormalizedHotkeyString[] {
return this._path
}

registerKeypress(hotkey: string): void {
this._path = [...this._path, hotkey]
get sequence(): NormalizedSequenceString {
return this._path.join(SEQUENCE_DELIMITER) as NormalizedSequenceString
}

registerKeypress(event: KeyboardEvent): void {
this._path = [...this._path, eventToHotkeyString(event)]
this.startTimer()
}

Expand All @@ -40,3 +56,10 @@ export default class SequenceTracker {
this.timer = window.setTimeout(() => this.reset(), SequenceTracker.CHORD_TIMEOUT)
}
}

export function normalizeSequence(sequence: string): NormalizedSequenceString {
return sequence
.split(SEQUENCE_DELIMITER)
.map(h => normalizeHotkey(h))
.join(SEQUENCE_DELIMITER) as NormalizedSequenceString
}
9 changes: 5 additions & 4 deletions src/utils.ts
@@ -1,4 +1,5 @@
import {normalizeHotkey} from './normalize-hotkey'
import {NormalizedHotkeyString, normalizeHotkey} from './hotkey'
import {SEQUENCE_DELIMITER} from './sequence'

export function isFormField(element: Node): boolean {
if (!(element instanceof HTMLElement)) {
Expand All @@ -20,7 +21,7 @@ export function isFormField(element: Node): boolean {
)
}

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

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

if (hotkey[i] === ' ') {
if (hotkey[i] === SEQUENCE_DELIMITER) {
// Spaces are used to separate key sequences, so a following comma is
// part of the sequence, not a separator.
acc.push('')
Expand Down

0 comments on commit 8071994

Please sign in to comment.