feat: Add mouse wheel support for hotkeys (#11324)

Co-authored-by: Eugene <inbox@null.page>
This commit is contained in:
KaniPan
2026-06-08 23:06:50 +02:00
committed by GitHub
co-authored by Eugene
parent 9308b161b5
commit 3142d125fa
8 changed files with 128 additions and 37 deletions
+70 -15
View File
@@ -17,10 +17,12 @@ interface PastKeystroke {
time: number
}
const WHEEL_EVENT_INTERVAL_MS = 250
@Injectable({ providedIn: 'root' })
export class HotkeysService {
/** @hidden @deprecated */
key = new EventEmitter<KeyboardEvent>()
key = new EventEmitter<KeyboardEvent|WheelEvent|MouseEvent>()
/** @hidden @deprecated */
matchedHotkey = new EventEmitter<string>()
@@ -52,7 +54,7 @@ export class HotkeysService {
/**
* Fired for each key event
*/
get keyEvent$ (): Observable<KeyboardEvent> { return this._keyEvent }
get keyEvent$ (): Observable<KeyboardEvent|WheelEvent|MouseEvent> { return this._keyEvent }
/**
* Fired for each singular key combination
@@ -61,7 +63,7 @@ export class HotkeysService {
private _hotkey = new Subject<string>()
private _hotkeyOff = new Subject<string>()
private _keyEvent = new Subject<KeyboardEvent>()
private _keyEvent = new Subject<KeyboardEvent|WheelEvent|MouseEvent>()
private _key = new Subject<KeyName>()
private _keystroke = new Subject<Keystroke>()
private disabledLevel = 0
@@ -73,7 +75,9 @@ export class HotkeysService {
private pressedKeystroke: Keystroke|null = null
private lastKeystrokes: PastKeystroke[] = []
private recognitionPhase = true
private suppressNextKeyupKeystroke = false
private lastEventTimestamp = 0
private lastWheelTimestamp: number|null = null
private constructor (
private zone: NgZone,
@@ -84,18 +88,29 @@ export class HotkeysService {
this.config.ready$.toPromise().then(async () => {
const hotkeys = await this.getHotkeyDescriptions()
this.hotkeyDescriptions = hotkeys
const events = ['keydown', 'keyup']
events.forEach(eventType => {
document.addEventListener(eventType, (nativeEvent: KeyboardEvent) => {
this._keyEvent.next(nativeEvent)
this.pushKeyEvent(eventType, nativeEvent)
const registerEvent = (
eventType: 'keydown'|'keyup'|'wheel'|'mouseup'|'auxclick',
filterEvent?: (event: KeyboardEvent|WheelEvent|MouseEvent) => boolean,
) => {
document.addEventListener(eventType, event => {
if (filterEvent && !filterEvent(event)) {
return
}
this._keyEvent.next(event)
this.pushKeyEvent(eventType, event)
if (hostApp.platform === Platform.Web && this.matchActiveHotkey(true) !== null) {
nativeEvent.preventDefault()
nativeEvent.stopPropagation()
event.preventDefault()
event.stopPropagation()
}
})
})
}
registerEvent('keydown')
registerEvent('keyup')
registerEvent('wheel')
registerEvent('mouseup', event => 'button' in event && event.button === 1)
registerEvent('auxclick', event => 'button' in event && event.button === 1)
})
// deprecated
@@ -111,7 +126,7 @@ export class HotkeysService {
* @param eventName DOM event name
* @param nativeEvent event object
*/
pushKeyEvent (eventName: string, nativeEvent: KeyboardEvent): void {
pushKeyEvent (eventName: string, nativeEvent: KeyboardEvent|WheelEvent|MouseEvent): void {
if (nativeEvent.timeStamp === this.lastEventTimestamp) {
return
}
@@ -123,8 +138,11 @@ export class HotkeysService {
metaKey: nativeEvent.metaKey,
altKey: nativeEvent.altKey,
shiftKey: nativeEvent.shiftKey,
code: nativeEvent.code,
key: nativeEvent.key,
code: 'code' in nativeEvent ? nativeEvent.code : '',
key: 'key' in nativeEvent ? nativeEvent.key : '',
deltaX: 'deltaX' in nativeEvent ? nativeEvent.deltaX : undefined,
deltaY: 'deltaY' in nativeEvent ? nativeEvent.deltaY : undefined,
button: 'button' in nativeEvent ? nativeEvent.button : undefined,
eventName,
time: nativeEvent.timeStamp,
registrationTime: performance.now(),
@@ -137,14 +155,28 @@ export class HotkeysService {
}
const keyName = getKeyName(eventData)
// During hotkey recording, ignore additional wheel events for a short interval
if (eventName === 'wheel' && !this.isEnabled()) {
if (this.lastWheelTimestamp !== null && nativeEvent.timeStamp - this.lastWheelTimestamp < WHEEL_EVENT_INTERVAL_MS) {
return
}
this.lastWheelTimestamp = nativeEvent.timeStamp
}
if (eventName === 'keydown') {
this.addPressedKey(keyName, eventData)
this.recognitionPhase = true
if (!(nativeEvent as KeyboardEvent).repeat) {
this.suppressNextKeyupKeystroke = false
}
this.updateModifiers(eventData)
}
if (eventName === 'keyup') {
const shouldSuppressKeystroke = this.suppressNextKeyupKeystroke
this.suppressNextKeyupKeystroke = false
const keystroke = getKeystrokeName([...this.pressedKeys])
if (this.recognitionPhase) {
if (!shouldSuppressKeystroke && keystroke && this.recognitionPhase) {
this._keystroke.next(keystroke)
this.lastKeystrokes.push({
keystroke,
@@ -156,6 +188,19 @@ export class HotkeysService {
this.pressedKeyTimestamps.clear()
this.removePressedKey(keyName)
}
if (eventName === 'wheel' || eventName === 'mouseup' || eventName === 'auxclick') {
this.updateModifiers(eventData)
this.addPressedKey(keyName, eventData)
const keystroke = getKeystrokeName([...this.pressedKeys])
this._keystroke.next(keystroke)
this.lastKeystrokes.push({
keystroke,
time: performance.now(),
})
this.pressedKeystroke = keystroke
this.recognitionPhase = true
this.suppressNextKeyupKeystroke = true
}
if (this.pressedKeys.size) {
this.pressedKeystroke = getKeystrokeName([...this.pressedKeys])
@@ -168,6 +213,9 @@ export class HotkeysService {
if (matched) {
if (this.recognitionPhase) {
this.emitHotkeyOn(matched)
if (eventName === 'wheel' || eventName === 'mouseup' || eventName === 'auxclick') {
this.emitHotkeyOff(matched)
}
}
} else if (this.pressedHotkey) {
this.emitHotkeyOff(this.pressedHotkey)
@@ -183,6 +231,13 @@ export class HotkeysService {
this.pushKeyEvent('keyup', nativeEvent)
}
if (eventName === 'wheel' || eventName === 'mouseup' || eventName === 'auxclick') {
this.recognitionPhase = false
this.pressedKeys.clear()
this.pressedKeyTimestamps.clear()
this.pressedKeystroke = null
}
this.lastEventTimestamp = nativeEvent.timeStamp
}
+26 -4
View File
@@ -16,8 +16,11 @@ export interface KeyEventData {
metaKey?: boolean
altKey?: boolean
shiftKey?: boolean
key: string
code: string
key?: string
code?: string
deltaX?: number
deltaY?: number
button?: number
eventName: string
time: number
registrationTime: number
@@ -29,6 +32,25 @@ export type KeyName = string
export type Keystroke = string
export function getKeyName (event: KeyEventData): KeyName {
if (event.eventName === 'mouseup' || event.eventName === 'auxclick') {
if (event.button === 1) {
return 'MiddleClick'
}
return 'Mouse'
}
if (event.eventName === 'wheel') {
const deltaX = event.deltaX ?? 0
const deltaY = event.deltaY ?? 0
if (Math.abs(deltaY) >= Math.abs(deltaX) && deltaY !== 0) {
return deltaY < 0 ? 'WheelUp' : 'WheelDown'
}
if (deltaX !== 0) {
return deltaX < 0 ? 'WheelLeft' : 'WheelRight'
}
return 'Wheel'
}
// eslint-disable-next-line @typescript-eslint/init-declarations
let key: string
if (event.key === 'Control') {
@@ -44,8 +66,8 @@ export function getKeyName (event: KeyEventData): KeyName {
} else if (event.key === '~') {
key = '~'
} else {
key = event.code
if (REGEX_LATIN_KEYNAME.test(event.key)) {
key = event.code ?? ''
if (event.key && REGEX_LATIN_KEYNAME.test(event.key)) {
// Handle Dvorak etc via the reported "character" instead of the scancode
key = event.key.toUpperCase()
} else {