allow modifier-only hotkeys

This commit is contained in:
Eugene Pankov
2021-07-31 17:18:03 +02:00
parent 8b8bacdf69
commit 1fc57018e3
5 changed files with 154 additions and 68 deletions

View File

@@ -25,6 +25,7 @@ export { DockingService, Screen } from '../services/docking.service'
export { Logger, ConsoleLogger, LogService } from '../services/log.service' export { Logger, ConsoleLogger, LogService } from '../services/log.service'
export { HomeBaseService } from '../services/homeBase.service' export { HomeBaseService } from '../services/homeBase.service'
export { HotkeysService } from '../services/hotkeys.service' export { HotkeysService } from '../services/hotkeys.service'
export { KeyEventData, KeySequenceItem } from '../services/hotkeys.util'
export { NotificationsService } from '../services/notifications.service' export { NotificationsService } from '../services/notifications.service'
export { ThemesService } from '../services/themes.service' export { ThemesService } from '../services/themes.service'
export { ProfilesService } from '../services/profiles.service' export { ProfilesService } from '../services/profiles.service'

View File

@@ -1,7 +1,7 @@
import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core' import { Injectable, Inject, NgZone, EventEmitter } from '@angular/core'
import { Observable, Subject } from 'rxjs' import { Observable, Subject } from 'rxjs'
import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider' import { HotkeyDescription, HotkeyProvider } from '../api/hotkeyProvider'
import { stringifyKeySequence, EventData } from './hotkeys.util' import { stringifyKeySequence, KeyEventData, KeySequenceItem } from './hotkeys.util'
import { ConfigService } from './config.service' import { ConfigService } from './config.service'
import { HostAppService, Platform } from '../api/hostApp' import { HostAppService, Platform } from '../api/hostApp'
import { deprecate } from 'util' import { deprecate } from 'util'
@@ -27,10 +27,25 @@ export class HotkeysService {
*/ */
get hotkey$ (): Observable<string> { return this._hotkey } get hotkey$ (): Observable<string> { return this._hotkey }
/**
* Fired for once hotkey is released
*/
get hotkeyOff$ (): Observable<string> { return this._hotkeyOff }
/**
* Fired for each recognized hotkey
*/
get key$ (): Observable<KeyboardEvent> { return this._key }
private _hotkey = new Subject<string>() private _hotkey = new Subject<string>()
private currentKeystrokes: EventData[] = [] private _hotkeyOff = new Subject<string>()
private _key = new Subject<KeyboardEvent>()
private currentEvents: KeyEventData[] = []
private disabledLevel = 0 private disabledLevel = 0
private hotkeyDescriptions: HotkeyDescription[] = [] private hotkeyDescriptions: HotkeyDescription[] = []
private pressedHotkey: string|null = null
private lastMatchedHotkeyStartTime = performance.now()
private lastMatchedHotkeyEndTime = performance.now()
private constructor ( private constructor (
private zone: NgZone, private zone: NgZone,
@@ -39,12 +54,10 @@ export class HotkeysService {
hostApp: HostAppService, hostApp: HostAppService,
) { ) {
const events = ['keydown', 'keyup'] const events = ['keydown', 'keyup']
events.forEach(event => { events.forEach(eventType => {
document.addEventListener(event, (nativeEvent: KeyboardEvent) => { document.addEventListener(eventType, (nativeEvent: KeyboardEvent) => {
if (document.querySelectorAll('input:focus').length === 0) { if (eventType === 'keyup' || document.querySelectorAll('input:focus').length === 0) {
this.pushKeystroke(event, nativeEvent) this.pushKeystroke(eventType, nativeEvent)
this.processKeystrokes()
this.emitKeyEvent(nativeEvent)
if (hostApp.platform === Platform.Web) { if (hostApp.platform === Platform.Web) {
nativeEvent.preventDefault() nativeEvent.preventDefault()
nativeEvent.stopPropagation() nativeEvent.stopPropagation()
@@ -60,6 +73,8 @@ export class HotkeysService {
// deprecated // deprecated
this.hotkey$.subscribe(h => this.matchedHotkey.emit(h)) this.hotkey$.subscribe(h => this.matchedHotkey.emit(h))
this.matchedHotkey.subscribe = deprecate(s => this.hotkey$.subscribe(s), 'matchedHotkey is deprecated, use hotkey$') this.matchedHotkey.subscribe = deprecate(s => this.hotkey$.subscribe(s), 'matchedHotkey is deprecated, use hotkey$')
this.key$.subscribe(e => this.key.emit(e))
} }
/** /**
@@ -70,7 +85,10 @@ export class HotkeysService {
*/ */
pushKeystroke (name: string, nativeEvent: KeyboardEvent): void { pushKeystroke (name: string, nativeEvent: KeyboardEvent): void {
nativeEvent['event'] = name nativeEvent['event'] = name
this.currentKeystrokes.push({ if (nativeEvent.timeStamp && this.currentEvents.find(x => x.time === nativeEvent.timeStamp)) {
return
}
this.currentEvents.push({
ctrlKey: nativeEvent.ctrlKey, ctrlKey: nativeEvent.ctrlKey,
metaKey: nativeEvent.metaKey, metaKey: nativeEvent.metaKey,
altKey: nativeEvent.altKey, altKey: nativeEvent.altKey,
@@ -78,8 +96,11 @@ export class HotkeysService {
code: nativeEvent.code, code: nativeEvent.code,
key: nativeEvent.key, key: nativeEvent.key,
eventName: name, eventName: name,
time: performance.now(), time: nativeEvent.timeStamp,
registrationTime: performance.now(),
}) })
this.processKeystrokes()
this.emitKeyEvent(nativeEvent)
} }
/** /**
@@ -88,53 +109,87 @@ export class HotkeysService {
processKeystrokes (): void { processKeystrokes (): void {
if (this.isEnabled()) { if (this.isEnabled()) {
this.zone.run(() => { this.zone.run(() => {
const matched = this.getCurrentFullyMatchedHotkey() let fullMatches: {
if (matched) { id: string,
console.log('Matched hotkey', matched) sequence: string[],
this._hotkey.next(matched) startTime: number,
this.clearCurrentKeystrokes() endTime: number,
} }[] = []
})
}
}
emitKeyEvent (nativeEvent: KeyboardEvent): void { const currentSequence = this.getCurrentKeySequence()
this.zone.run(() => {
this.key.emit(nativeEvent)
})
}
clearCurrentKeystrokes (): void {
this.currentKeystrokes = []
}
getCurrentKeystrokes (): string[] {
this.currentKeystrokes = this.currentKeystrokes.filter(x => performance.now() - x.time < KEY_TIMEOUT)
return stringifyKeySequence(this.currentKeystrokes)
}
getCurrentFullyMatchedHotkey (): string|null {
const currentStrokes = this.getCurrentKeystrokes()
const config = this.getHotkeysConfig() const config = this.getHotkeysConfig()
for (const id in config) { for (const id in config) {
for (const sequence of config[id]) { for (const sequence of config[id]) {
if (currentStrokes.length < sequence.length) { if (currentSequence.length < sequence.length) {
continue continue
} }
if (sequence.every( if (sequence.every(
(x: string, index: number) => (x: string, index: number) =>
x.toLowerCase() === x.toLowerCase() ===
currentStrokes[currentStrokes.length - sequence.length + index].toLowerCase() currentSequence[currentSequence.length - sequence.length + index].value.toLowerCase()
)) { )) {
return id fullMatches.push({
id: id,
sequence,
startTime: currentSequence[currentSequence.length - sequence.length].firstEvent.registrationTime,
endTime: currentSequence[currentSequence.length - 1].lastEvent.registrationTime,
})
} }
} }
} }
return null
fullMatches.sort((a, b) => b.startTime - a.startTime + (b.sequence.length - a.sequence.length))
fullMatches = fullMatches.filter(x => x.startTime >= this.lastMatchedHotkeyStartTime)
fullMatches = fullMatches.filter(x => x.endTime > this.lastMatchedHotkeyEndTime)
const matched = fullMatches[0]?.id
if (matched) {
this.emitHotkeyOn(matched)
this.lastMatchedHotkeyStartTime = fullMatches[0].startTime
this.lastMatchedHotkeyEndTime = fullMatches[0].endTime
} else if (this.pressedHotkey) {
this.emitHotkeyOff(this.pressedHotkey)
}
})
}
}
private emitHotkeyOn (hotkey: string) {
if (this.pressedHotkey) {
this.emitHotkeyOff(this.pressedHotkey)
}
console.debug('Matched hotkey', hotkey)
this._hotkey.next(hotkey)
this.pressedHotkey = hotkey
}
private emitHotkeyOff (hotkey: string) {
console.debug('Unmatched hotkey', hotkey)
this._hotkeyOff.next(hotkey)
this.pressedHotkey = null
}
emitKeyEvent (nativeEvent: KeyboardEvent): void {
this.zone.run(() => {
this._key.next(nativeEvent)
})
}
clearCurrentKeystrokes (): void {
this.currentEvents = []
}
getCurrentKeySequence (): KeySequenceItem[] {
this.currentEvents = this.currentEvents.filter(x => performance.now() - x.time < KEY_TIMEOUT && x.registrationTime >= this.lastMatchedHotkeyStartTime)
return stringifyKeySequence(this.currentEvents)
}
getCurrentFullyMatchedHotkey (): string|null {
return this.pressedHotkey
} }
getCurrentPartiallyMatchedHotkeys (): PartialHotkeyMatch[] { getCurrentPartiallyMatchedHotkeys (): PartialHotkeyMatch[] {
const currentStrokes = this.getCurrentKeystrokes() const currentStrokes = this.getCurrentKeySequence().map(x => x.value)
const config = this.getHotkeysConfig() const config = this.getHotkeysConfig()
const result: PartialHotkeyMatch[] = [] const result: PartialHotkeyMatch[] = []
for (const id in config) { for (const id in config) {

View File

@@ -10,46 +10,66 @@ export const altKeyName = {
linux: 'Alt', linux: 'Alt',
}[process.platform] }[process.platform]
export interface EventData { export interface KeyEventData {
ctrlKey: boolean ctrlKey?: boolean
metaKey: boolean metaKey?: boolean
altKey: boolean altKey?: boolean
shiftKey: boolean shiftKey?: boolean
key: string key: string
code: string code: string
eventName: string eventName: string
time: number time: number
registrationTime: number
} }
const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/ const REGEX_LATIN_KEYNAME = /^[A-Za-z]$/
export function stringifyKeySequence (events: EventData[]): string[] { export interface KeySequenceItem {
const items: string[] = [] value: string
firstEvent: KeyEventData
lastEvent: KeyEventData
}
export function stringifyKeySequence (events: KeyEventData[]): KeySequenceItem[] {
const items: KeySequenceItem[] = []
let pressedKeys: KeySequenceItem[] = []
events = events.slice() events = events.slice()
const strictOrdering = ['Ctrl', metaKeyName, altKeyName, 'Shift']
function flushPressedKeys () {
if (pressedKeys.length) {
const v = {
firstEvent: pressedKeys[0].firstEvent,
lastEvent: pressedKeys[pressedKeys.length - 1].lastEvent,
}
pressedKeys = [
...strictOrdering.map(x => pressedKeys.find(p => p.value === x)).filter(x => !!x) as KeySequenceItem[],
...pressedKeys.filter(p => !strictOrdering.includes(p.value)),
]
items.push({
value: pressedKeys.map(x => x.value).join('-'),
...v,
})
pressedKeys = []
}
}
while (events.length > 0) { while (events.length > 0) {
const event = events.shift()! const event = events.shift()!
if (event.eventName === 'keydown') {
const itemKeys: string[] = []
if (event.ctrlKey) {
itemKeys.push('Ctrl')
}
if (event.metaKey) {
itemKeys.push(metaKeyName)
}
if (event.altKey) {
itemKeys.push(altKeyName)
}
if (event.shiftKey) {
itemKeys.push('Shift')
}
if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) { // eslint-disable-next-line @typescript-eslint/init-declarations
// TODO make this optional? let key: string
continue if (event.key === 'Control') {
} key = 'Ctrl'
} else if (event.key === 'Meta') {
let key = event.code key = metaKeyName
} else if (event.key === 'Alt') {
key = altKeyName
} else if (event.key === 'Shift') {
key = 'Shift'
} else {
key = event.code
if (REGEX_LATIN_KEYNAME.test(event.key)) { if (REGEX_LATIN_KEYNAME.test(event.key)) {
// Handle Dvorak etc via the reported "character" instead of the scancode // Handle Dvorak etc via the reported "character" instead of the scancode
key = event.key.toUpperCase() key = event.key.toUpperCase()
@@ -72,10 +92,20 @@ export function stringifyKeySequence (events: EventData[]): string[] {
BracketRight: ']', BracketRight: ']',
}[key] ?? key }[key] ?? key
} }
}
itemKeys.push(key) if (event.eventName === 'keydown') {
items.push(itemKeys.join('-')) pressedKeys.push({
value: key,
firstEvent: event,
lastEvent: event,
})
}
if (event.eventName === 'keyup') {
flushPressedKeys()
} }
} }
flushPressedKeys()
return items return items
} }

View File

@@ -50,7 +50,7 @@ export class HotkeyInputModalComponent extends BaseComponent {
this.hotkeys.clearCurrentKeystrokes() this.hotkeys.clearCurrentKeystrokes()
this.subscribeUntilDestroyed(hotkeys.key, (event) => { this.subscribeUntilDestroyed(hotkeys.key, (event) => {
this.lastKeyEvent = performance.now() this.lastKeyEvent = performance.now()
this.value = this.hotkeys.getCurrentKeystrokes() this.value = this.hotkeys.getCurrentKeySequence().map(x => x.value)
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}) })

View File

@@ -97,7 +97,7 @@ export default class TerminalModule { // eslint-disable-line @typescript-eslint/
htermHandler: 'onKeyUp_', htermHandler: 'onKeyUp_',
}, },
] ]
events.forEach((event) => { events.forEach(event => {
const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler] const oldHandler = hterm.hterm.Keyboard.prototype[event.htermHandler]
hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) { hterm.hterm.Keyboard.prototype[event.htermHandler] = function (nativeEvent) {
hotkeys.pushKeystroke(event.name, nativeEvent) hotkeys.pushKeystroke(event.name, nativeEvent)