Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose tools for building & normalizing hotkey & sequence strings #94

Merged
merged 11 commits into from
Oct 10, 2023
6 changes: 3 additions & 3 deletions pages/hotkey_mapper.html
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Loading