Skip to content

Commit

Permalink
feat: Improve keybind handling. (#12)
Browse files Browse the repository at this point in the history
Should now detect <C-p> even if p was pressed down first
  • Loading branch information
Otard95 authored Jun 26, 2023
1 parent 0291972 commit 515142a
Showing 1 changed file with 115 additions and 26 deletions.
141 changes: 115 additions & 26 deletions src/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const alphabet = [
'z',
] as const

const keys = [
const regularKeys = [
...alphabet,
'arrowup',
'arrowdown',
Expand All @@ -59,13 +59,33 @@ const keys = [
'escape',
'enter',
'backspace',
'delete',
'home',
'end',
'pageup',
'pagedown',
'space',
'insert',
'pause',
'capslock',
] as const

const mods = ['control', 'alt', 'shift', 'meta'] as const

const keys = [...regularKeys, ...mods] as const

// type Letter = (typeof alphabet)[number]
type RegularKey = (typeof regularKeys)[number]
type Key = (typeof keys)[number]
type KeyGroup = Key | `<C-${Key}>` | `<A-${Key}>` | `<S-${Key}>`

type KeybindCallback = (preventDefault: () => void) => void
type Mod = (typeof mods)[number]
type KeyGroup =
| RegularKey
| `<C-${RegularKey}>`
| `<A-${RegularKey}>`
| `<S-${RegularKey}>`
| `<M-${RegularKey}>`

type KeybindCallback = (preventDefault: () => void) => Promise<void> | void
interface Keybind {
keySequence: KeyGroup[]
command: KeybindCallback
Expand All @@ -74,36 +94,42 @@ interface Keybind {

export default class Keybinds {
private keybinds: Keybind[] = []
private heldKeys: Key[] = []
private pressedKeys: EphemeralArray<KeyGroup> = new EphemeralArray(1000)

constructor(element: HTMLElement | Document = document.body) {
constructor(element: HTMLElement | Document | Window = window) {
element.addEventListener('keydown', event => {
if (event instanceof KeyboardEvent) this.handleKeyDown(event)
})

element.addEventListener('keyup', event => {
if (event instanceof KeyboardEvent) this.handleKeyUp(event)
})

this.pressedKeys.onTimeout(this.handlePressedOnTimeout.bind(this))
}

private isKey(keyStr: string): keyStr is KeyGroup {
if (keys.includes(keyStr as Key)) return true
private isKeyGroup(keyStr: string): keyStr is KeyGroup {
if (regularKeys.includes(keyStr as RegularKey)) return true

if (!/<(C|A|S){1,3}-\w+>/.test(keyStr)) return false
if (!/<(C|A|S|M){1,4}-\w+>/.test(keyStr)) return false

const [, modifiers, key] = keyStr.match(/<([CAS]+)-(\w+)>/) as [
const [, modifiers, key] = keyStr.match(/<([CASM]+)-(\w+)>/) as [
string,
string,
Key
RegularKey
]

const mods = modifiers.split('')
return (
keys.includes(key as Key) && mods.every(m => ['C', 'A', 'S'].includes(m))
regularKeys.includes(key as RegularKey) &&
mods.every(m => ['C', 'A', 'S', 'M'].includes(m))
)
}

private parseBind(bind: string): KeyGroup[] {
const keys = bind.trim().split(/\s+/)
if (keys.some(key => !this.isKey(key)))
if (keys.some(key => !this.isKeyGroup(key)))
throw new Error(`Invalid keybind: ${bind}`)

return keys as KeyGroup[]
Expand Down Expand Up @@ -137,41 +163,102 @@ export default class Keybinds {
})
}

private keyFromKeyboardEvent(event: KeyboardEvent): KeyGroup | null {
const { key, ctrlKey, altKey, shiftKey } = event
private isModifier(key: Key): key is Mod {
return mods.includes(key as Mod)
}

private isRegularKey(key: Key): key is RegularKey {
return regularKeys.includes(key as RegularKey)
}

private modToKeyGroupMod(mod: Mod): string {
switch (mod) {
case 'control':
return 'C'
case 'alt':
return 'A'
case 'shift':
return 'S'
case 'meta':
return 'M'
}
}

private keyGroupFromHeldKeys(): KeyGroup | null {
if (this.heldKeys.length === 0) return null
if (this.heldKeys.length === 1 && this.isRegularKey(this.heldKeys[0]))
return this.heldKeys[0]

if (!keys.includes(key.toLowerCase() as Key)) return null
const modifiers = this.heldKeys.filter(this.isModifier)
const keys = this.heldKeys.filter(this.isRegularKey)

if (!ctrlKey && !altKey && !shiftKey) return key.toLowerCase() as Key
if (keys.length === 0 || keys.length > 1) return null

const modifiers = [
ctrlKey ? 'C' : '',
altKey ? 'A' : '',
shiftKey ? 'S' : '',
].join('')
const keyGroupMods = modifiers.map(this.modToKeyGroupMod).join('')

return `<${modifiers}-${key.toLowerCase()}>` as KeyGroup
return `<${keyGroupMods}-${keys[0]}>` as KeyGroup
}

private handleKeyDown(event: KeyboardEvent) {
const key = this.keyFromKeyboardEvent(event)
if (key === null) return
const key = event.key.toLowerCase() as RegularKey
if (!keys.includes(key)) return

console.debug(`[PrUn Palette](Keybinds) ↓${key}`)

this.heldKeys.push(key)

const keyGroup = this.keyGroupFromHeldKeys()
if (keyGroup !== null && keyGroup.includes('<')) {
// If a modifier key and a regular key is pressed already, we can
// assume that the user is trying to press a keybind that includes the
// modifier. For example, if the user presses `shift` while holding `a`,
// we can assume that they want to press `shift-a`, and not `shift`, and
// that they don't need any other modifiers.
this.pressedKeys.push(keyGroup)
this.handleKeyPressed(event)
this.heldKeys = []
}
}

private handleKeyUp(event: KeyboardEvent) {
const key = event.key.toLowerCase() as RegularKey
if (!keys.includes(key)) return

console.debug(`[PrUn Palette](Keybinds) ↑${key}`)

const keyGroup = this.keyGroupFromHeldKeys()
if (keyGroup !== null) {
this.pressedKeys.push(keyGroup)
this.handleKeyPressed(event)
this.heldKeys = []
}

this.pressedKeys.push(key)
this.heldKeys = this.heldKeys.filter(k => k !== key)
}

private handleKeyPressed(event: KeyboardEvent) {
console.debug('[PrUn Palette](Keybinds) handleKeyPressed', { event })

// Find any keybinds that match the current key presses, including those
// that could become matching.
const matchingKeybinds = this.keybinds.filter(keybind => {
return arrayStartsWith(keybind.keySequence, this.pressedKeys)
})

console.debug(
'[PrUn Palette](Keybinds) matching keybinds',
matchingKeybinds
)

// If there are no matches we cant do anything
if (matchingKeybinds.length === 0) return

// If there are more than one we can't make a decision yet on which is
// correct.
if (matchingKeybinds.length > 1) {
event.preventDefault()
console.debug(
'[PrUn Palette](Keybinds) multiple matching keybinds, waiting for more input'
)
return
}

Expand All @@ -180,6 +267,8 @@ export default class Keybinds {
arrayEqual(keybind.keySequence, this.pressedKeys)
)

console.debug('[PrUn Palette](Keybinds) exact match', match)

if (!match) return

if (match.preventDefault) event.preventDefault()
Expand Down

0 comments on commit 515142a

Please sign in to comment.