refactor(keybinding): extract chord state machine into core and fix AltGr guard
This commit is contained in:
parent
de1e8a82a4
commit
9cd81b48c1
3 changed files with 960 additions and 355 deletions
|
|
@ -1,13 +1,12 @@
|
|||
// src/main/services/KeyBindingService.ts
|
||||
// uiohook-napi 기반 글로벌 키바인딩 후킹 서비스.
|
||||
// uiohook-napi 기반 글로벌 키바인딩 후킹 어댑터.
|
||||
//
|
||||
// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts` 다.
|
||||
// 이 파일은 두 가지만 책임진다:
|
||||
// 바인딩 계약(키 목록·기본값·정규화·검증·충돌 판정)의 정본은 `packages/core/src/keybinding.ts`,
|
||||
// press / release · 더블프레스 · auto-repeat · AltGr 보류 상태 머신의 정본은
|
||||
// `packages/core/src/keybinding-runtime.ts` 다. 이 파일은 입출력 어댑터만 책임진다:
|
||||
// 1) 정본 좌표계(Windows VK / MouseButton) ↔ uiohook 이벤트 좌표계 변환
|
||||
// 2) press / release · 더블프레스 · auto-repeat 런타임 상태 머신
|
||||
//
|
||||
// 런타임 상태는 액션 id 가 아니라 bindingKey() 로 키잉한다 —
|
||||
// 한 액션에 여러 바인딩이 붙고, 여러 액션이 한 바인딩을 공유하기 때문이다.
|
||||
// 2) 설정 로딩 → 상태 머신 바인딩 등록
|
||||
// 3) macOS beep 차단용 globalShortcut accelerator 관리
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { globalShortcut } from 'electron'
|
||||
|
|
@ -17,7 +16,6 @@ import { getLogger } from './LoggerService'
|
|||
import { acquireGlobalInputHook } from './global-input-hook'
|
||||
import { configGet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { TIMING } from '@d3ro/core/constants'
|
||||
import {
|
||||
KEYBINDING_ACTIONS,
|
||||
MouseButton,
|
||||
|
|
@ -25,82 +23,42 @@ import {
|
|||
bindingKey,
|
||||
normalizeBinding
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { ChordStateMachine } from '@d3ro/core/keybinding-runtime'
|
||||
import type {
|
||||
KeyBinding,
|
||||
KeyBindingActionId,
|
||||
KeyBindingActionSpec,
|
||||
MouseButtonCode
|
||||
} from '@d3ro/core/types'
|
||||
ChordBindingEntry,
|
||||
ChordClock,
|
||||
ChordTriggerEvent
|
||||
} from '@d3ro/core/keybinding-runtime'
|
||||
import type { KeyBinding, KeyBindingActionSpec, MouseButtonCode } from '@d3ro/core/types'
|
||||
|
||||
const logger = getLogger('KeyBindingService')
|
||||
|
||||
/**
|
||||
* AltGr(=Ctrl+Alt) 함정 가드.
|
||||
*
|
||||
* 오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표 같은 조합을 누르면
|
||||
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다(실측 신고: 제안 탐색 대신
|
||||
* 음성 파이프라인이 돌았다). 수정자만으로 끝나면서 Ctrl+Alt 가 함께 눌린 트리거는
|
||||
* 잠깐 보류하고, 그 사이에 다른 키가 눌리면(=조합을 만들려던 것) 취소한다.
|
||||
*
|
||||
* 받아쓰기(수정자 단독, Ctrl 없음)와 Alt+Shift 계열은 모양이 달라 영향받지 않는다.
|
||||
*/
|
||||
const ALTGR_CHORD_GRACE_MS = 280
|
||||
|
||||
/** 수정자 키의 VK 코드 (수정자만으로 끝나는 조합 판별). */
|
||||
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
|
||||
0x10, // Shift
|
||||
0x11, // Ctrl
|
||||
0x12, // Alt
|
||||
0x5b, // LWin
|
||||
0x5c, // RWin
|
||||
0xa0,
|
||||
0xa1, // L/R Shift
|
||||
0xa2,
|
||||
0xa3, // L/R Ctrl
|
||||
0xa4,
|
||||
0xa5 // L/R Alt
|
||||
])
|
||||
|
||||
let pendingAltGrEmit: NodeJS.Timeout | null = null
|
||||
/** 보류가 취소된 바인딩 — 대응하는 released 를 내보내지 않기 위해 기억한다. */
|
||||
const cancelledAltGrBindings = new Set<string>()
|
||||
|
||||
// ============================================================
|
||||
// 이벤트 페이로드
|
||||
// ============================================================
|
||||
|
||||
export interface KeyBindingTriggerPayload {
|
||||
actionId: KeyBindingActionId
|
||||
type: 'pressed' | 'released'
|
||||
/** 더블프레스로 트리거된 누름인지 */
|
||||
isDoublePress: boolean
|
||||
/** KEYBINDING_ACTIONS 의 holdMode — 소비자가 hold-to-talk / 토글을 분기하는 데 쓴다 */
|
||||
holdMode: boolean
|
||||
timestamp: number
|
||||
/** type === 'released' 일 때의 누름 유지 시간 (pressed 는 0) */
|
||||
durationMs: number
|
||||
/**
|
||||
* 매칭된 바인딩의 주 키 코드와 수정자 상태.
|
||||
*
|
||||
* 소비자가 "수정자만으로 끝나는 조합인지" 를 판단해 AltGr 함정을 피하는 데 쓴다.
|
||||
* (오른쪽 Alt 는 Windows 가 Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면
|
||||
* Alt 를 누르는 순간 Ctrl+AltRight 바인딩이 먼저 발동한다)
|
||||
*/
|
||||
binding: {
|
||||
device: 'keyboard' | 'mouse'
|
||||
code: number
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
shift: boolean
|
||||
meta: boolean
|
||||
}
|
||||
}
|
||||
/**
|
||||
* `triggered` 이벤트 페이로드. 정본은 core 의 ChordTriggerEvent 다.
|
||||
*
|
||||
* binding 은 매칭된 바인딩(정규화 완료)의 주 키 코드와 수정자 상태다.
|
||||
*/
|
||||
export type KeyBindingTriggerPayload = ChordTriggerEvent
|
||||
|
||||
interface KeyBindingServiceEvents {
|
||||
triggered: (payload: KeyBindingTriggerPayload) => void
|
||||
error: (payload: { error: D3ROError }) => void
|
||||
}
|
||||
|
||||
/** 실제 시계. 보류 타이머가 프로세스 종료를 붙잡지 않도록 unref 한다. */
|
||||
const systemClock: ChordClock = {
|
||||
now: () => Date.now(),
|
||||
schedule: (callback, delayMs) => {
|
||||
const handle = setTimeout(callback, delayMs)
|
||||
handle.unref?.()
|
||||
return () => clearTimeout(handle)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Windows VK ↔ uiohook 키코드
|
||||
// ============================================================
|
||||
|
|
@ -258,22 +216,6 @@ export function uiohookCodeToVk(code: number): number | null {
|
|||
return UIOHOOK_TO_VK.get(code) ?? null
|
||||
}
|
||||
|
||||
function isCtrlCode(code: number): boolean {
|
||||
return code === UiohookKey.Ctrl || code === UiohookKey.CtrlRight
|
||||
}
|
||||
|
||||
function isAltCode(code: number): boolean {
|
||||
return code === UiohookKey.Alt || code === UiohookKey.AltRight
|
||||
}
|
||||
|
||||
function isShiftCode(code: number): boolean {
|
||||
return code === UiohookKey.Shift || code === UiohookKey.ShiftRight
|
||||
}
|
||||
|
||||
function isMetaCode(code: number): boolean {
|
||||
return code === UiohookKey.Meta || code === UiohookKey.MetaRight
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 마우스 버튼 좁히기
|
||||
// ============================================================
|
||||
|
|
@ -365,66 +307,38 @@ function bindingToAccelerator(binding: KeyBinding): string | null {
|
|||
// 내부 타입
|
||||
// ============================================================
|
||||
|
||||
interface RegisteredBinding {
|
||||
actionId: KeyBindingActionId
|
||||
interface RegisteredAction {
|
||||
spec: KeyBindingActionSpec
|
||||
/** 정본 좌표계 바인딩 (정규화 완료) */
|
||||
binding: KeyBinding
|
||||
/** keyboard → uiohook 키코드 / mouse → MouseButton 코드 */
|
||||
eventCode: number
|
||||
/** macOS beep 차단용 accelerator. 마우스 바인딩과 변환 불가 키는 null */
|
||||
accelerator: string | null
|
||||
}
|
||||
|
||||
interface ActiveTrigger {
|
||||
actionId: KeyBindingActionId
|
||||
isDoublePress: boolean
|
||||
holdMode: boolean
|
||||
/** 눌림을 만든 바인딩 (AltGr 형태 판정에 필요) */
|
||||
entry: RegisteredBinding
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// KeyBindingService
|
||||
// ============================================================
|
||||
|
||||
/** AltGr(=Ctrl+Alt) 형태의 수정자 전용 조합인가. */
|
||||
function isAltGrShaped(binding: KeyBinding): boolean {
|
||||
if (binding.device !== 'keyboard') return false
|
||||
if (!binding.ctrl || !binding.alt) return false
|
||||
return MODIFIER_VK_CODES.has(binding.code)
|
||||
}
|
||||
|
||||
class KeyBindingService extends EventEmitter {
|
||||
private _isRunning = false
|
||||
|
||||
/** bindingKey → 그 바인딩을 쓰는 액션들. 매칭은 이 역인덱스 조회로 끝난다. */
|
||||
private _byBindingKey: Map<string, RegisteredBinding[]> = new Map()
|
||||
/** press / release · 더블프레스 · AltGr 보류 판정 (순수 정책, core) */
|
||||
private readonly _machine = new ChordStateMachine({
|
||||
clock: systemClock,
|
||||
onTrigger: (event) => this.emit('triggered', event),
|
||||
debug: (message) => logger.debug(message)
|
||||
})
|
||||
|
||||
/** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */
|
||||
private _ownedAccelerators: Set<string> = new Set()
|
||||
|
||||
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 바인딩 */
|
||||
private _isKeyDown: Map<string, boolean> = new Map()
|
||||
|
||||
/** hold duration 계산용: press 시작 시각 */
|
||||
private _pressStartTime: Map<string, number> = new Map()
|
||||
|
||||
/** 더블프레스 감지용: 마지막 press 시각 */
|
||||
private _lastPressTime: Map<string, number> = new Map()
|
||||
|
||||
/** 현재 누름에서 실제로 트리거된 액션 — release 를 같은 대상에게만 보낸다 */
|
||||
private _activeTriggers: Map<string, ActiveTrigger[]> = new Map()
|
||||
|
||||
private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||
private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||
private _onMouseDown: ((e: UiohookMouseEvent) => void) | null = null
|
||||
private _onMouseUp: ((e: UiohookMouseEvent) => void) | null = null
|
||||
|
||||
/**
|
||||
* 글로벌 후 해제 함수.
|
||||
* 글로벌 후킹 해제 함수.
|
||||
*
|
||||
* 후킹은 프로세스 전역 글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
|
||||
* 후킹은 프로세스 전역 싱글턴이고 InputTelemetryService 도 같은 후킹을 쓴다.
|
||||
* 직접 stop 하면 상대방 수신을 죽이므로 참조 카운트 해제자를 보관한다.
|
||||
*/
|
||||
private _releaseHook: (() => void) | null = null
|
||||
|
|
@ -435,7 +349,7 @@ class KeyBindingService extends EventEmitter {
|
|||
|
||||
/** 등록된 바인딩 수 (bindingKey 기준) */
|
||||
get registeredBindingCount(): number {
|
||||
return this._byBindingKey.size
|
||||
return this._machine.registeredBindingCount
|
||||
}
|
||||
|
||||
// ── 수명 주기 ──────────────────────────────────────────
|
||||
|
|
@ -515,75 +429,59 @@ class KeyBindingService extends EventEmitter {
|
|||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
this._byBindingKey.clear()
|
||||
// setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다.
|
||||
this._machine.setBindings([])
|
||||
this._unregisterOwnedAccelerators()
|
||||
this._clearRuntimeState()
|
||||
this.removeAllListeners()
|
||||
logger.info('KeyBindingService disposed')
|
||||
}
|
||||
|
||||
// ── 등록 ───────────────────────────────────────────────
|
||||
|
||||
/** ConfigService 의 keyBindings 를 읽어 역인덱스를 다시 만든다. */
|
||||
/** ConfigService 의 keyBindings 를 읽어 상태 머신 바인딩을 다시 만든다. */
|
||||
loadFromConfig(): void {
|
||||
this._byBindingKey.clear()
|
||||
this._clearRuntimeState()
|
||||
|
||||
if (!configGet('hotkeyEnabled')) {
|
||||
// setBindings 는 런타임 상태(보류 타이머 포함)도 함께 비운다.
|
||||
this._machine.setBindings([])
|
||||
this._unregisterOwnedAccelerators()
|
||||
logger.info('Key bindings disabled in config')
|
||||
return
|
||||
}
|
||||
|
||||
const map = configGet('keyBindings')
|
||||
const entries: ChordBindingEntry[] = []
|
||||
for (const spec of KEYBINDING_ACTIONS) {
|
||||
for (const raw of map[spec.id] ?? []) {
|
||||
this._register(spec, raw)
|
||||
const registered = this._toObservable(spec, raw)
|
||||
if (registered === null) continue
|
||||
entries.push({
|
||||
actionId: registered.spec.id,
|
||||
holdMode: registered.spec.holdMode,
|
||||
doublePress: registered.spec.doublePress,
|
||||
binding: registered.binding
|
||||
})
|
||||
}
|
||||
}
|
||||
this._machine.setBindings(entries)
|
||||
|
||||
this._syncAccelerators()
|
||||
logger.info(
|
||||
`Loaded ${this._byBindingKey.size} key binding(s) from config ` +
|
||||
`Loaded ${this._machine.registeredBindingCount} key binding(s) from config ` +
|
||||
`for ${KEYBINDING_ACTIONS.length} action(s)`
|
||||
)
|
||||
}
|
||||
|
||||
private _register(spec: KeyBindingActionSpec, raw: KeyBinding): void {
|
||||
/** uiohook 이벤트로 관측할 수 있는 바인딩만 통과시킨다. */
|
||||
private _toObservable(spec: KeyBindingActionSpec, raw: KeyBinding): RegisteredAction | null {
|
||||
const binding = normalizeBinding(raw)
|
||||
|
||||
let eventCode: number | null
|
||||
if (binding.device === 'mouse') {
|
||||
eventCode = binding.code
|
||||
} else {
|
||||
eventCode = VK_TO_UIOHOOK.get(binding.code) ?? null
|
||||
}
|
||||
|
||||
if (eventCode === null) {
|
||||
if (binding.device === 'keyboard' && !VK_TO_UIOHOOK.has(binding.code)) {
|
||||
logger.warn(
|
||||
`Key code 0x${binding.code.toString(16)} has no uiohook equivalent ` +
|
||||
`— binding for action "${spec.id}" is not registered`
|
||||
)
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
const key = bindingKey(binding)
|
||||
const entry: RegisteredBinding = {
|
||||
actionId: spec.id,
|
||||
spec,
|
||||
binding,
|
||||
eventCode,
|
||||
accelerator: binding.device === 'keyboard' ? bindingToAccelerator(binding) : null
|
||||
}
|
||||
|
||||
const existing = this._byBindingKey.get(key)
|
||||
if (existing === undefined) {
|
||||
this._byBindingKey.set(key, [entry])
|
||||
return
|
||||
}
|
||||
// 같은 액션이 같은 바인딩을 두 번 들고 있으면 한 번 누를 때 두 번 트리거된다.
|
||||
if (existing.some((registered) => registered.actionId === spec.id)) return
|
||||
existing.push(entry)
|
||||
return { spec, binding }
|
||||
}
|
||||
|
||||
// ── accelerator (macOS beep 차단) ──────────────────────
|
||||
|
|
@ -592,10 +490,10 @@ class KeyBindingService extends EventEmitter {
|
|||
this._unregisterOwnedAccelerators()
|
||||
if (!this._isRunning) return
|
||||
|
||||
for (const entries of this._byBindingKey.values()) {
|
||||
// 같은 bindingKey 를 공유하는 엔트리는 바인딩이 동일하므로 accelerator 도 같다.
|
||||
const accelerator = entries[0]?.accelerator
|
||||
if (accelerator === undefined || accelerator === null) continue
|
||||
for (const binding of this._machine.registeredBindings()) {
|
||||
if (binding.device !== 'keyboard') continue
|
||||
const accelerator = bindingToAccelerator(binding)
|
||||
if (accelerator === null) continue
|
||||
if (this._ownedAccelerators.has(accelerator)) continue
|
||||
|
||||
try {
|
||||
|
|
@ -628,23 +526,20 @@ class KeyBindingService extends EventEmitter {
|
|||
this._ownedAccelerators.clear()
|
||||
}
|
||||
|
||||
/** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */
|
||||
private _clearRuntimeState(): void {
|
||||
this._isKeyDown.clear()
|
||||
this._pressStartTime.clear()
|
||||
this._lastPressTime.clear()
|
||||
this._activeTriggers.clear()
|
||||
this._machine.reset()
|
||||
}
|
||||
|
||||
// ── 이벤트 → 바인딩 매칭 ───────────────────────────────
|
||||
// ── uiohook 이벤트 → 정본 좌표계 ──────────────────────
|
||||
|
||||
/**
|
||||
* uiohook 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
|
||||
* uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
|
||||
* normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로
|
||||
* Right Alt 단독 바인딩도 그대로 매칭된다.
|
||||
*/
|
||||
private _keyboardEventKey(e: UiohookKeyboardEvent): string | null {
|
||||
const vk = UIOHOOK_TO_VK.get(e.keycode)
|
||||
if (vk === undefined) return null
|
||||
private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null {
|
||||
if (vk === null) return null
|
||||
return bindingKey({
|
||||
device: 'keyboard',
|
||||
code: vk,
|
||||
|
|
@ -669,202 +564,31 @@ class KeyBindingService extends EventEmitter {
|
|||
}
|
||||
|
||||
private _handleKeyDown(e: UiohookKeyboardEvent): void {
|
||||
const key = this._keyboardEventKey(e)
|
||||
const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode))
|
||||
if (key === null) return
|
||||
this._handlePress(key)
|
||||
this._machine.keyDown(key)
|
||||
}
|
||||
|
||||
private _handleMouseDown(e: UiohookMouseEvent): void {
|
||||
const key = this._mouseEventKey(e)
|
||||
if (key === null) return
|
||||
this._handlePress(key)
|
||||
this._machine.keyDown(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 업.
|
||||
* Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않아야 하므로,
|
||||
* 정확 매칭이 실패하면 눌림 상태인 바인딩의 구성 키가 놓였는지도 확인한다.
|
||||
* 키 업. Alt+1 같은 조합에서 수정자를 먼저 놓아도 release 를 놓치지 않도록
|
||||
* 놓인 키의 VK 를 함께 넘긴다 — 정확 매칭이 실패하면 상태 머신이 구성 키 여부를 본다.
|
||||
*/
|
||||
private _handleKeyUp(e: UiohookKeyboardEvent): void {
|
||||
const key = this._keyboardEventKey(e)
|
||||
if (key !== null && this._isKeyDown.get(key) === true) {
|
||||
this._fireRelease(key)
|
||||
return
|
||||
}
|
||||
this._releaseByComponentKey(e.keycode)
|
||||
const vk = uiohookCodeToVk(e.keycode)
|
||||
this._machine.keyUp(this._keyboardEventKey(e, vk), vk)
|
||||
}
|
||||
|
||||
/** 마우스 업은 정확 매칭만 한다. */
|
||||
private _handleMouseUp(e: UiohookMouseEvent): void {
|
||||
const key = this._mouseEventKey(e)
|
||||
if (key !== null && this._isKeyDown.get(key) === true) {
|
||||
this._fireRelease(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** 눌림 상태인 바인딩 중 놓인 키가 주 키/수정자인 것을 release 시킨다. */
|
||||
private _releaseByComponentKey(uiohookCode: number): void {
|
||||
for (const [key, entries] of this._byBindingKey) {
|
||||
if (this._isKeyDown.get(key) !== true) continue
|
||||
const entry = entries[0]
|
||||
if (entry === undefined) continue
|
||||
|
||||
const isMainKey =
|
||||
entry.binding.device === 'keyboard' && entry.eventCode === uiohookCode
|
||||
if (isMainKey || this._isModifierComponent(uiohookCode, entry.binding)) {
|
||||
this._fireRelease(key)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _isModifierComponent(uiohookCode: number, binding: KeyBinding): boolean {
|
||||
if (binding.ctrl && isCtrlCode(uiohookCode)) return true
|
||||
if (binding.alt && isAltCode(uiohookCode)) return true
|
||||
if (binding.shift && isShiftCode(uiohookCode)) return true
|
||||
if (binding.meta && isMetaCode(uiohookCode)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// ── press / release ────────────────────────────────────
|
||||
|
||||
private _handlePress(key: string): void {
|
||||
const entries = this._byBindingKey.get(key)
|
||||
if (entries === undefined || entries.length === 0) return
|
||||
|
||||
// 키 반복(auto-repeat) 무시
|
||||
if (this._isKeyDown.get(key) === true) return
|
||||
this._isKeyDown.set(key, true)
|
||||
|
||||
const now = Date.now()
|
||||
this._pressStartTime.set(key, now)
|
||||
|
||||
// 더블프레스 감지는 이 바인딩을 쓰는 액션 중 하나라도 doublePress 일 때만 한다.
|
||||
// 그렇지 않으면 단일프레스 액션을 빠르게 두 번 누를 때 두 번째가 삼켜진다.
|
||||
let isDoublePress = false
|
||||
if (entries.some((entry) => entry.spec.doublePress)) {
|
||||
const lastPress = this._lastPressTime.get(key)
|
||||
isDoublePress =
|
||||
lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION
|
||||
if (isDoublePress) {
|
||||
this._lastPressTime.delete(key)
|
||||
} else {
|
||||
this._lastPressTime.set(key, now)
|
||||
}
|
||||
}
|
||||
|
||||
// dictation(단일) 과 hands-free(더블) 는 같은 바인딩을 공유한다 —
|
||||
// 첫 매치만 반환하지 않고 doublePress 여부로 갈라 해당하는 액션을 모두 트리거한다.
|
||||
const triggers: ActiveTrigger[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.spec.doublePress !== isDoublePress) continue
|
||||
triggers.push({
|
||||
actionId: entry.actionId,
|
||||
isDoublePress,
|
||||
holdMode: entry.spec.holdMode,
|
||||
entry
|
||||
})
|
||||
}
|
||||
this._activeTriggers.set(key, triggers)
|
||||
|
||||
for (const trigger of triggers) {
|
||||
logger.debug(`Key binding pressed: "${trigger.actionId}" (double=${isDoublePress})`)
|
||||
this._emitPressed(trigger.entry, trigger, now, key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 눌림 이벤트를 내보낸다.
|
||||
*
|
||||
* AltGr 형태(수정자 전용 + Ctrl+Alt)는 곧 다른 키가 이어질 수 있으므로 잠깐
|
||||
* 보류한다. 그 사이 다른 바인딩의 눌림이 오면 취소한다 — 오른쪽 Alt 는 Windows 가
|
||||
* Ctrl+Alt 로 보내므로, Ctrl+Alt+화살표를 누르면 Alt 순간에 Ctrl+AltRight(음성 명령)
|
||||
* 가 먼저 발동하던 문제를 이 지점에서 막는다.
|
||||
*/
|
||||
private _emitPressed(
|
||||
entry: RegisteredBinding,
|
||||
trigger: ActiveTrigger,
|
||||
now: number,
|
||||
bindingKeyValue: string
|
||||
): void {
|
||||
const payload: KeyBindingTriggerPayload = {
|
||||
actionId: trigger.actionId,
|
||||
type: 'pressed',
|
||||
isDoublePress: trigger.isDoublePress,
|
||||
holdMode: trigger.holdMode,
|
||||
timestamp: now,
|
||||
durationMs: 0,
|
||||
binding: {
|
||||
device: entry.binding.device,
|
||||
code: entry.binding.code,
|
||||
ctrl: entry.binding.ctrl,
|
||||
alt: entry.binding.alt,
|
||||
shift: entry.binding.shift,
|
||||
meta: entry.binding.meta
|
||||
}
|
||||
}
|
||||
|
||||
if (isAltGrShaped(entry.binding)) {
|
||||
if (pendingAltGrEmit) clearTimeout(pendingAltGrEmit)
|
||||
const actionId = trigger.actionId
|
||||
// 취소 표시는 _fireRelease 가 쓰는 것과 같은 키여야 한다 (device:code 조합이 아니다).
|
||||
cancelledAltGrBindings.add(actionId + ':' + bindingKeyValue)
|
||||
pendingAltGrEmit = setTimeout(() => {
|
||||
pendingAltGrEmit = null
|
||||
logger.debug(`AltGr 형태 트리거 유예 후 발동: "${actionId}"`)
|
||||
this.emit('triggered', payload)
|
||||
}, ALTGR_CHORD_GRACE_MS)
|
||||
pendingAltGrEmit.unref?.()
|
||||
return
|
||||
}
|
||||
|
||||
// 다른 키가 이어졌다 = 조합을 만들려던 것 → 보류 중인 AltGr 트리거는 취소.
|
||||
if (pendingAltGrEmit) {
|
||||
clearTimeout(pendingAltGrEmit)
|
||||
pendingAltGrEmit = null
|
||||
logger.debug('AltGr 형태 보류 트리거 취소 (다른 키가 이어짐)')
|
||||
}
|
||||
|
||||
this.emit('triggered', payload)
|
||||
}
|
||||
|
||||
private _fireRelease(key: string): void {
|
||||
this._isKeyDown.set(key, false)
|
||||
|
||||
const now = Date.now()
|
||||
const pressStart = this._pressStartTime.get(key)
|
||||
const durationMs = pressStart !== undefined ? now - pressStart : 0
|
||||
this._pressStartTime.delete(key)
|
||||
|
||||
const triggers = this._activeTriggers.get(key) ?? []
|
||||
this._activeTriggers.delete(key)
|
||||
|
||||
for (const trigger of triggers) {
|
||||
logger.debug(
|
||||
`Key binding released: "${trigger.actionId}" (duration=${durationMs}ms)`
|
||||
)
|
||||
if (cancelledAltGrBindings.delete(trigger.actionId + ':' + key)) {
|
||||
logger.debug(`AltGr 보류가 취소된 바인딩의 released 는 내보내지 않는다: "${trigger.actionId}"`)
|
||||
continue
|
||||
}
|
||||
|
||||
this.emit('triggered', {
|
||||
actionId: trigger.actionId,
|
||||
type: 'released',
|
||||
isDoublePress: trigger.isDoublePress,
|
||||
holdMode: trigger.holdMode,
|
||||
timestamp: now,
|
||||
durationMs,
|
||||
binding: {
|
||||
device: 'keyboard',
|
||||
code: 0,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false
|
||||
}
|
||||
})
|
||||
}
|
||||
if (key === null) return
|
||||
this._machine.keyUp(key, null)
|
||||
}
|
||||
|
||||
// ── EventEmitter 타입 오버라이드 ───────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue