1350 lines
41 KiB
TypeScript
1350 lines
41 KiB
TypeScript
// packages/core/src/keybinding.ts
|
|
//
|
|
// 키바인딩 SSOT (Single Source of Truth).
|
|
//
|
|
// 이 모듈이 다음 전부의 정본이다:
|
|
// 1) 바인딩 표현 — KeyBinding (키보드 VK / 마우스 버튼 공용)
|
|
// 2) 바인딩 가능한 액션 — KEYBINDING_ACTIONS
|
|
// 3) 선택 가능한 키 목록 — KEY_CATALOG (드롭다운 · 검색 · 검증 공용)
|
|
// 4) 동일성 / 정규화 — bindingKey(), normalizeBinding()
|
|
// 5) 검증 / 충돌 판정 — validateBinding(), detectBindingConflicts()
|
|
// 6) 표시 라벨 — formatBindingSegments()
|
|
//
|
|
// 메인 프로세스(KeyBindingService), preload, 렌더러 UI는 전부 이 모듈만 쓴다.
|
|
// 키 목록·라벨·검증 규칙을 다른 곳에 다시 정의하지 않는다.
|
|
//
|
|
// 좌표계 규약:
|
|
// device === 'keyboard' → code 는 **Windows Virtual-Key 코드**
|
|
// device === 'mouse' → code 는 **MouseButton 값 (libuiohook MOUSE_BUTTON1..5)**
|
|
// uiohook 키코드로의 변환은 메인 프로세스 전용 어댑터가 담당한다.
|
|
// 저장·IPC·UI 는 언제나 이 좌표계를 쓴다.
|
|
|
|
// ============================================================
|
|
// 기본 타입
|
|
// ============================================================
|
|
|
|
/** 바인딩 입력 장치 */
|
|
export type BindingDevice = 'keyboard' | 'mouse'
|
|
|
|
/**
|
|
* 마우스 버튼 코드 (libuiohook `MOUSE_BUTTON1..5`).
|
|
* uiohook-napi 가 상수를 export 하지 않으므로 여기서 정의한다.
|
|
*/
|
|
export const MouseButton = {
|
|
Left: 1,
|
|
Right: 2,
|
|
Middle: 3,
|
|
/** X1 — 일반적으로 "뒤로" */
|
|
Back: 4,
|
|
/** X2 — 일반적으로 "앞으로" */
|
|
Forward: 5
|
|
} as const
|
|
|
|
export type MouseButtonCode = (typeof MouseButton)[keyof typeof MouseButton]
|
|
|
|
/**
|
|
* 단일 키바인딩. 저장·IPC·UI 전 구간 공용 정본 표현.
|
|
*
|
|
* `displayLabel` 같은 파생 문자열은 담지 않는다 —
|
|
* 라벨은 언제나 formatBindingSegments()로 파생시킨다(로케일·플랫폼 변경에 따라감).
|
|
*/
|
|
export interface KeyBinding {
|
|
device: BindingDevice
|
|
/** keyboard → Windows VK 코드 / mouse → MouseButtonCode */
|
|
code: number
|
|
ctrl: boolean
|
|
alt: boolean
|
|
shift: boolean
|
|
meta: boolean
|
|
}
|
|
|
|
/** 액션 하나에 바인딩된 목록 (0개 이상 — 다중 바인딩) */
|
|
export type KeyBindingList = readonly KeyBinding[]
|
|
|
|
/** 전체 바인딩 맵 — 액션 id → 바인딩 목록 */
|
|
export type KeyBindingMap = Record<KeyBindingActionId, KeyBinding[]>
|
|
|
|
// ============================================================
|
|
// Windows VK 상수 (이 모듈에서 쓰는 것만)
|
|
// ============================================================
|
|
|
|
export const VK = {
|
|
Backspace: 0x08,
|
|
Tab: 0x09,
|
|
Enter: 0x0d,
|
|
Pause: 0x13,
|
|
CapsLock: 0x14,
|
|
Escape: 0x1b,
|
|
Space: 0x20,
|
|
PageUp: 0x21,
|
|
PageDown: 0x22,
|
|
End: 0x23,
|
|
Home: 0x24,
|
|
ArrowLeft: 0x25,
|
|
ArrowUp: 0x26,
|
|
ArrowRight: 0x27,
|
|
ArrowDown: 0x28,
|
|
PrintScreen: 0x2c,
|
|
Insert: 0x2d,
|
|
Delete: 0x2e,
|
|
Digit0: 0x30,
|
|
Digit9: 0x39,
|
|
A: 0x41,
|
|
Z: 0x5a,
|
|
MetaLeft: 0x5b,
|
|
MetaRight: 0x5c,
|
|
Numpad0: 0x60,
|
|
Numpad9: 0x69,
|
|
NumpadMultiply: 0x6a,
|
|
NumpadAdd: 0x6b,
|
|
NumpadSubtract: 0x6d,
|
|
NumpadDecimal: 0x6e,
|
|
NumpadDivide: 0x6f,
|
|
F1: 0x70,
|
|
F24: 0x87,
|
|
NumLock: 0x90,
|
|
ScrollLock: 0x91,
|
|
ShiftLeft: 0xa0,
|
|
ShiftRight: 0xa1,
|
|
CtrlLeft: 0xa2,
|
|
CtrlRight: 0xa3,
|
|
AltLeft: 0xa4,
|
|
AltRight: 0xa5,
|
|
Semicolon: 0xba,
|
|
Equal: 0xbb,
|
|
Comma: 0xbc,
|
|
Minus: 0xbd,
|
|
Period: 0xbe,
|
|
Slash: 0xbf,
|
|
Backquote: 0xc0,
|
|
BracketLeft: 0xdb,
|
|
Backslash: 0xdc,
|
|
BracketRight: 0xdd,
|
|
Quote: 0xde
|
|
} as const
|
|
|
|
/**
|
|
* 수정자 키 자체의 VK 코드.
|
|
* 좌/우 구분 코드와, 브라우저 KeyboardEvent 가 주는 비구분 코드(16/17/18)를 모두 포함한다.
|
|
*/
|
|
const MODIFIER_VK_CODES: ReadonlySet<number> = new Set([
|
|
0x10, 0x11, 0x12, // Shift / Ctrl / Alt (좌우 비구분 — 브라우저 이벤트)
|
|
VK.ShiftLeft,
|
|
VK.ShiftRight,
|
|
VK.CtrlLeft,
|
|
VK.CtrlRight,
|
|
VK.AltLeft,
|
|
VK.AltRight,
|
|
VK.MetaLeft,
|
|
VK.MetaRight
|
|
])
|
|
|
|
/** 해당 VK 코드가 수정자 키 자체인지 */
|
|
export function isModifierKeyCode(code: number): boolean {
|
|
return MODIFIER_VK_CODES.has(code)
|
|
}
|
|
|
|
/**
|
|
* 수정자 키 자체를 주 키로 쓸 때, 함께 켜지는 수정자 플래그를 판정한다.
|
|
* 예) 주 키가 Right Alt 이면 alt 플래그는 "자기 자신"이므로 조합으로 치지 않는다.
|
|
*/
|
|
function selfModifierOf(code: number): keyof Pick<
|
|
KeyBinding,
|
|
'ctrl' | 'alt' | 'shift' | 'meta'
|
|
> | null {
|
|
if (code === 0x10 || code === VK.ShiftLeft || code === VK.ShiftRight) return 'shift'
|
|
if (code === 0x11 || code === VK.CtrlLeft || code === VK.CtrlRight) return 'ctrl'
|
|
if (code === 0x12 || code === VK.AltLeft || code === VK.AltRight) return 'alt'
|
|
if (code === VK.MetaLeft || code === VK.MetaRight) return 'meta'
|
|
return null
|
|
}
|
|
|
|
// ============================================================
|
|
// 키 카탈로그 — 드롭다운 / 검색 / 검증 공용 원천
|
|
// ============================================================
|
|
|
|
export type KeyCatalogGroup =
|
|
| 'mouse'
|
|
| 'modifier'
|
|
| 'function'
|
|
| 'letter'
|
|
| 'digit'
|
|
| 'numpad'
|
|
| 'navigation'
|
|
| 'editing'
|
|
| 'punctuation'
|
|
| 'system'
|
|
|
|
/**
|
|
* 선택 가능한 키 하나.
|
|
*
|
|
* `label` 은 로케일 무관 키캡 표기(국제 표준: 'A', 'Ctrl', 'F5', '←')이며 번역하지 않는다.
|
|
* `labelKey` 가 있는 항목만 t() 로 번역해 표시한다(마우스 버튼처럼 설명적 이름이 필요한 경우).
|
|
*/
|
|
export interface KeyCatalogEntry {
|
|
device: BindingDevice
|
|
code: number
|
|
group: KeyCatalogGroup
|
|
/** 키캡 표기 (번역 대상 아님) */
|
|
label: string
|
|
/** macOS 전용 키캡 표기 — 없으면 label 사용 */
|
|
macLabel?: string
|
|
/** 번역이 필요한 설명적 이름의 i18n 키 (없으면 label 을 그대로 표시) */
|
|
labelKey?: string
|
|
/** 검색용 영문 별칭 (소문자) */
|
|
aliases: readonly string[]
|
|
/** 단독 사용 불가 — 수정자 1개 이상 필요 */
|
|
requiresModifier: boolean
|
|
/** 바인딩 자체가 불가능한 경우의 사유 i18n 키 (선택 목록에 비활성으로 노출) */
|
|
disabledReasonKey?: string
|
|
/**
|
|
* 눌러도 원래 동작이 함께 실행된다는 경고의 i18n 키.
|
|
* uiohook-napi 는 이벤트 suppress 가 불가능하다(비동기 디스패치).
|
|
*/
|
|
passthroughWarningKey?: string
|
|
}
|
|
|
|
const LETTER_ALIASES: Readonly<Record<string, readonly string[]>> = {}
|
|
|
|
function letterEntries(): KeyCatalogEntry[] {
|
|
const out: KeyCatalogEntry[] = []
|
|
for (let code = VK.A; code <= VK.Z; code += 1) {
|
|
const label = String.fromCharCode(code)
|
|
out.push({
|
|
device: 'keyboard',
|
|
code,
|
|
group: 'letter',
|
|
label,
|
|
aliases: LETTER_ALIASES[label] ?? [label.toLowerCase()],
|
|
requiresModifier: true
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function digitEntries(): KeyCatalogEntry[] {
|
|
const out: KeyCatalogEntry[] = []
|
|
for (let code = VK.Digit0; code <= VK.Digit9; code += 1) {
|
|
const label = String.fromCharCode(code)
|
|
out.push({
|
|
device: 'keyboard',
|
|
code,
|
|
group: 'digit',
|
|
label,
|
|
aliases: [label, `digit${label}`],
|
|
requiresModifier: true
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function functionEntries(): KeyCatalogEntry[] {
|
|
const out: KeyCatalogEntry[] = []
|
|
for (let code = VK.F1; code <= VK.F24; code += 1) {
|
|
const n = code - VK.F1 + 1
|
|
out.push({
|
|
device: 'keyboard',
|
|
code,
|
|
group: 'function',
|
|
label: `F${n}`,
|
|
aliases: [`f${n}`, `function${n}`],
|
|
// 기능 키는 단독 바인딩이 관례적으로 안전하다.
|
|
requiresModifier: false
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
function numpadEntries(): KeyCatalogEntry[] {
|
|
const out: KeyCatalogEntry[] = []
|
|
for (let code = VK.Numpad0; code <= VK.Numpad9; code += 1) {
|
|
const n = code - VK.Numpad0
|
|
out.push({
|
|
device: 'keyboard',
|
|
code,
|
|
group: 'numpad',
|
|
label: `Num${n}`,
|
|
aliases: [`num${n}`, `numpad${n}`, `keypad${n}`],
|
|
requiresModifier: true
|
|
})
|
|
}
|
|
const ops: readonly (readonly [number, string, readonly string[]])[] = [
|
|
[VK.NumpadDivide, 'Num/', ['numdivide', 'numpaddivide']],
|
|
[VK.NumpadMultiply, 'Num*', ['nummultiply', 'numpadmultiply']],
|
|
[VK.NumpadSubtract, 'Num-', ['numsubtract', 'numpadminus']],
|
|
[VK.NumpadAdd, 'Num+', ['numadd', 'numpadplus']],
|
|
[VK.NumpadDecimal, 'Num.', ['numdecimal', 'numpaddot']]
|
|
]
|
|
for (const [code, label, aliases] of ops) {
|
|
out.push({
|
|
device: 'keyboard',
|
|
code,
|
|
group: 'numpad',
|
|
label,
|
|
aliases,
|
|
requiresModifier: true
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
const MODIFIER_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.CtrlLeft,
|
|
group: 'modifier',
|
|
label: 'Left Ctrl',
|
|
macLabel: 'Left ⌃',
|
|
aliases: ['leftctrl', 'lctrl', 'control'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.CtrlRight,
|
|
group: 'modifier',
|
|
label: 'Right Ctrl',
|
|
macLabel: 'Right ⌃',
|
|
aliases: ['rightctrl', 'rctrl', 'control'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.AltLeft,
|
|
group: 'modifier',
|
|
label: 'Left Alt',
|
|
macLabel: 'Left ⌥',
|
|
aliases: ['leftalt', 'lalt', 'option'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.AltRight,
|
|
group: 'modifier',
|
|
label: 'Right Alt',
|
|
macLabel: 'Right ⌥',
|
|
aliases: ['rightalt', 'ralt', 'altgr', 'option'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ShiftLeft,
|
|
group: 'modifier',
|
|
label: 'Left Shift',
|
|
macLabel: 'Left ⇧',
|
|
aliases: ['leftshift', 'lshift'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ShiftRight,
|
|
group: 'modifier',
|
|
label: 'Right Shift',
|
|
macLabel: 'Right ⇧',
|
|
aliases: ['rightshift', 'rshift'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.MetaLeft,
|
|
group: 'modifier',
|
|
label: 'Left Win',
|
|
macLabel: 'Left ⌘',
|
|
aliases: ['leftwin', 'lwin', 'command', 'super'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.MetaRight,
|
|
group: 'modifier',
|
|
label: 'Right Win',
|
|
macLabel: 'Right ⌘',
|
|
aliases: ['rightwin', 'rwin', 'command', 'super'],
|
|
requiresModifier: false
|
|
}
|
|
]
|
|
|
|
const EDITING_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Space,
|
|
group: 'editing',
|
|
label: 'Space',
|
|
aliases: ['space', 'spacebar'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Enter,
|
|
group: 'editing',
|
|
label: 'Enter',
|
|
macLabel: '↩',
|
|
aliases: ['enter', 'return'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Tab,
|
|
group: 'editing',
|
|
label: 'Tab',
|
|
aliases: ['tab'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Backspace,
|
|
group: 'editing',
|
|
label: 'Backspace',
|
|
macLabel: '⌫',
|
|
aliases: ['backspace'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Escape,
|
|
group: 'editing',
|
|
label: 'Esc',
|
|
aliases: ['esc', 'escape'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.CapsLock,
|
|
group: 'editing',
|
|
label: 'CapsLock',
|
|
aliases: ['capslock', 'caps'],
|
|
requiresModifier: true
|
|
}
|
|
]
|
|
|
|
const NAVIGATION_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Insert,
|
|
group: 'navigation',
|
|
label: 'Insert',
|
|
aliases: ['insert', 'ins'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Delete,
|
|
group: 'navigation',
|
|
label: 'Delete',
|
|
macLabel: '⌦',
|
|
aliases: ['delete', 'del'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Home,
|
|
group: 'navigation',
|
|
label: 'Home',
|
|
aliases: ['home'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.End,
|
|
group: 'navigation',
|
|
label: 'End',
|
|
aliases: ['end'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.PageUp,
|
|
group: 'navigation',
|
|
label: 'PgUp',
|
|
aliases: ['pageup', 'pgup'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.PageDown,
|
|
group: 'navigation',
|
|
label: 'PgDn',
|
|
aliases: ['pagedown', 'pgdn'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ArrowLeft,
|
|
group: 'navigation',
|
|
label: '←',
|
|
aliases: ['left', 'arrowleft'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ArrowUp,
|
|
group: 'navigation',
|
|
label: '↑',
|
|
aliases: ['up', 'arrowup'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ArrowRight,
|
|
group: 'navigation',
|
|
label: '→',
|
|
aliases: ['right', 'arrowright'],
|
|
requiresModifier: true
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ArrowDown,
|
|
group: 'navigation',
|
|
label: '↓',
|
|
aliases: ['down', 'arrowdown'],
|
|
requiresModifier: true
|
|
}
|
|
]
|
|
|
|
const PUNCTUATION_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{ device: 'keyboard', code: VK.Semicolon, group: 'punctuation', label: ';', aliases: ['semicolon'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Equal, group: 'punctuation', label: '=', aliases: ['equal', 'plus'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Comma, group: 'punctuation', label: ',', aliases: ['comma'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Minus, group: 'punctuation', label: '-', aliases: ['minus', 'dash', 'hyphen'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Period, group: 'punctuation', label: '.', aliases: ['period', 'dot'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Slash, group: 'punctuation', label: '/', aliases: ['slash'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Backquote, group: 'punctuation', label: '`', aliases: ['backquote', 'grave', 'tilde'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.BracketLeft, group: 'punctuation', label: '[', aliases: ['bracketleft'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Backslash, group: 'punctuation', label: '\\', aliases: ['backslash'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.BracketRight, group: 'punctuation', label: ']', aliases: ['bracketright'], requiresModifier: true },
|
|
{ device: 'keyboard', code: VK.Quote, group: 'punctuation', label: "'", aliases: ['quote', 'apostrophe'], requiresModifier: true }
|
|
]
|
|
|
|
const SYSTEM_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.PrintScreen,
|
|
group: 'system',
|
|
label: 'PrtSc',
|
|
aliases: ['printscreen', 'prtsc'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.ScrollLock,
|
|
group: 'system',
|
|
label: 'ScrLk',
|
|
aliases: ['scrolllock', 'scrlk'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.Pause,
|
|
group: 'system',
|
|
label: 'Pause',
|
|
aliases: ['pause', 'break'],
|
|
requiresModifier: false
|
|
},
|
|
{
|
|
device: 'keyboard',
|
|
code: VK.NumLock,
|
|
group: 'system',
|
|
label: 'NumLock',
|
|
aliases: ['numlock'],
|
|
requiresModifier: true
|
|
}
|
|
]
|
|
|
|
/**
|
|
* 마우스 버튼.
|
|
*
|
|
* uiohook-napi 는 이벤트를 삼킬 수 없으므로(비동기 디스패치) 바인딩해도 원래 동작이 함께 실행된다.
|
|
* 따라서:
|
|
* - 좌클릭은 바인딩 금지 (모든 UI 조작이 트리거를 일으킨다)
|
|
* - 우클릭 / 가운데 클릭은 수정자 필수
|
|
* - X1 / X2 는 단독 허용 (기본 동작이 앱 탐색 정도라 부작용이 작다)
|
|
*/
|
|
const MOUSE_ENTRIES: readonly KeyCatalogEntry[] = [
|
|
{
|
|
device: 'mouse',
|
|
code: MouseButton.Left,
|
|
group: 'mouse',
|
|
label: 'Mouse Left',
|
|
labelKey: 'keybinding.mouse.left',
|
|
aliases: ['mouseleft', 'leftclick', 'lmb', 'mb1'],
|
|
requiresModifier: true,
|
|
disabledReasonKey: 'keybinding.disabled.mouseLeft'
|
|
},
|
|
{
|
|
device: 'mouse',
|
|
code: MouseButton.Right,
|
|
group: 'mouse',
|
|
label: 'Mouse Right',
|
|
labelKey: 'keybinding.mouse.right',
|
|
aliases: ['mouseright', 'rightclick', 'rmb', 'mb2'],
|
|
requiresModifier: true,
|
|
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
|
|
},
|
|
{
|
|
device: 'mouse',
|
|
code: MouseButton.Middle,
|
|
group: 'mouse',
|
|
label: 'Mouse Middle',
|
|
labelKey: 'keybinding.mouse.middle',
|
|
aliases: ['mousemiddle', 'middleclick', 'mmb', 'mb3', 'wheelclick'],
|
|
requiresModifier: true,
|
|
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
|
|
},
|
|
{
|
|
device: 'mouse',
|
|
code: MouseButton.Back,
|
|
group: 'mouse',
|
|
label: 'Mouse Back',
|
|
labelKey: 'keybinding.mouse.back',
|
|
aliases: ['mouseback', 'mouse4', 'mb4', 'xbutton1', 'thumb'],
|
|
requiresModifier: false,
|
|
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
|
|
},
|
|
{
|
|
device: 'mouse',
|
|
code: MouseButton.Forward,
|
|
group: 'mouse',
|
|
label: 'Mouse Forward',
|
|
labelKey: 'keybinding.mouse.forward',
|
|
aliases: ['mouseforward', 'mouse5', 'mb5', 'xbutton2', 'thumb'],
|
|
requiresModifier: false,
|
|
passthroughWarningKey: 'keybinding.warning.mousePassthrough'
|
|
}
|
|
]
|
|
|
|
/** 선택 가능한 전체 키 목록. 드롭다운 표시 순서이기도 하다. */
|
|
export const KEY_CATALOG: readonly KeyCatalogEntry[] = Object.freeze([
|
|
...MOUSE_ENTRIES,
|
|
...MODIFIER_ENTRIES,
|
|
...functionEntries(),
|
|
...letterEntries(),
|
|
...digitEntries(),
|
|
...NAVIGATION_ENTRIES,
|
|
...EDITING_ENTRIES,
|
|
...numpadEntries(),
|
|
...PUNCTUATION_ENTRIES,
|
|
...SYSTEM_ENTRIES
|
|
])
|
|
|
|
/** 그룹 표시 순서 (UI 섹션 헤더 순서) */
|
|
export const KEY_CATALOG_GROUP_ORDER: readonly KeyCatalogGroup[] = Object.freeze([
|
|
'mouse',
|
|
'modifier',
|
|
'function',
|
|
'letter',
|
|
'digit',
|
|
'navigation',
|
|
'editing',
|
|
'numpad',
|
|
'punctuation',
|
|
'system'
|
|
])
|
|
|
|
/** 그룹 라벨 i18n 키 */
|
|
export const KEY_CATALOG_GROUP_LABEL_KEYS: Readonly<Record<KeyCatalogGroup, string>> =
|
|
Object.freeze({
|
|
mouse: 'keybinding.group.mouse',
|
|
modifier: 'keybinding.group.modifier',
|
|
function: 'keybinding.group.function',
|
|
letter: 'keybinding.group.letter',
|
|
digit: 'keybinding.group.digit',
|
|
numpad: 'keybinding.group.numpad',
|
|
navigation: 'keybinding.group.navigation',
|
|
editing: 'keybinding.group.editing',
|
|
punctuation: 'keybinding.group.punctuation',
|
|
system: 'keybinding.group.system'
|
|
})
|
|
|
|
const CATALOG_INDEX: ReadonlyMap<string, KeyCatalogEntry> = new Map(
|
|
KEY_CATALOG.map((entry) => [`${entry.device}:${entry.code}`, entry])
|
|
)
|
|
|
|
/** device + code 로 카탈로그 항목을 찾는다. 없으면 null. */
|
|
export function findKeyCatalogEntry(
|
|
device: BindingDevice,
|
|
code: number
|
|
): KeyCatalogEntry | null {
|
|
return CATALOG_INDEX.get(`${device}:${code}`) ?? null
|
|
}
|
|
|
|
// ============================================================
|
|
// 액션 카탈로그 — 바인딩 가능한 기능 목록
|
|
// ============================================================
|
|
|
|
export type KeyBindingActionId =
|
|
| 'dictation'
|
|
| 'hands-free'
|
|
| 'command'
|
|
| 'caption'
|
|
| 'history-popup'
|
|
| 'command-popup'
|
|
| 'suggestion-accept'
|
|
| 'suggestion-next'
|
|
| 'suggestion-prev'
|
|
| 'suggestion-dismiss'
|
|
|
|
/** 액션 그룹 (설정 화면 섹션) */
|
|
export type KeyBindingActionGroup = 'voice' | 'window' | 'input'
|
|
|
|
export interface KeyBindingActionSpec {
|
|
id: KeyBindingActionId
|
|
group: KeyBindingActionGroup
|
|
/** 액션 이름 i18n 키 */
|
|
labelKey: string
|
|
/** 액션 설명 i18n 키 */
|
|
descriptionKey: string
|
|
/** 누르고 있는 동안 활성(hold-to-talk) 방식인지 */
|
|
holdMode: boolean
|
|
/** 더블프레스로 트리거되는지 */
|
|
doublePress: boolean
|
|
/** 기본 바인딩 */
|
|
defaultBindings: readonly KeyBinding[]
|
|
}
|
|
|
|
/** 키보드 바인딩을 간결하게 만든다. 기본값(옛 기본값 등)을 구성할 때 이 모듈 밖에서도 쓴다. */
|
|
export function kb(
|
|
code: number,
|
|
mods: Partial<Pick<KeyBinding, 'ctrl' | 'alt' | 'shift' | 'meta'>> = {}
|
|
): KeyBinding {
|
|
return {
|
|
device: 'keyboard',
|
|
code,
|
|
ctrl: mods.ctrl ?? false,
|
|
alt: mods.alt ?? false,
|
|
shift: mods.shift ?? false,
|
|
meta: mods.meta ?? false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 바인딩 가능한 전체 액션.
|
|
*
|
|
* 하드코딩 단축키를 남기지 않는다 — 앱의 모든 전역 단축키는 이 목록에 있어야 하고,
|
|
* 전부 같은 방식으로 재바인딩된다.
|
|
*/
|
|
export const KEYBINDING_ACTIONS: readonly KeyBindingActionSpec[] = Object.freeze([
|
|
{
|
|
id: 'dictation',
|
|
group: 'voice',
|
|
labelKey: 'keybinding.action.dictation',
|
|
descriptionKey: 'keybinding.action.dictation.desc',
|
|
holdMode: true,
|
|
doublePress: false,
|
|
defaultBindings: [kb(VK.AltRight)]
|
|
},
|
|
{
|
|
id: 'hands-free',
|
|
group: 'voice',
|
|
labelKey: 'keybinding.action.handsFree',
|
|
descriptionKey: 'keybinding.action.handsFree.desc',
|
|
holdMode: false,
|
|
doublePress: true,
|
|
// dictation 과 같은 키를 더블프레스로 구분한다.
|
|
defaultBindings: [kb(VK.AltRight)]
|
|
},
|
|
{
|
|
id: 'command',
|
|
group: 'voice',
|
|
labelKey: 'keybinding.action.command',
|
|
descriptionKey: 'keybinding.action.command.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(VK.AltRight, { ctrl: true })]
|
|
},
|
|
{
|
|
id: 'caption',
|
|
group: 'voice',
|
|
labelKey: 'keybinding.action.caption',
|
|
descriptionKey: 'keybinding.action.caption.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(VK.AltRight, { ctrl: true, shift: true })]
|
|
},
|
|
{
|
|
id: 'history-popup',
|
|
group: 'window',
|
|
labelKey: 'keybinding.action.historyPopup',
|
|
descriptionKey: 'keybinding.action.historyPopup.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(0x56 /* V */, { ctrl: true, shift: true })]
|
|
},
|
|
{
|
|
id: 'command-popup',
|
|
group: 'window',
|
|
labelKey: 'keybinding.action.commandPopup',
|
|
descriptionKey: 'keybinding.action.commandPopup.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(0x43 /* C */, { ctrl: true, shift: true })]
|
|
},
|
|
{
|
|
id: 'suggestion-accept',
|
|
group: 'input',
|
|
labelKey: 'keybinding.action.suggestionAccept',
|
|
descriptionKey: 'keybinding.action.suggestionAccept.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
// Ctrl+Alt+↑/↓ 로 후보를 오가고(페이지는 따라 넘어간다), 수락은 Enter.
|
|
// 좌우 화살표는 쓰지 않는다 — Intel 그래픽 드라이버의 화면 회전 단축키와 겹친다.
|
|
defaultBindings: [kb(VK.Enter, { ctrl: true, alt: true })]
|
|
},
|
|
{
|
|
id: 'suggestion-next',
|
|
group: 'input',
|
|
labelKey: 'keybinding.action.suggestionNext',
|
|
descriptionKey: 'keybinding.action.suggestionNext.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(VK.ArrowDown, { ctrl: true, alt: true })]
|
|
},
|
|
{
|
|
id: 'suggestion-prev',
|
|
group: 'input',
|
|
labelKey: 'keybinding.action.suggestionPrev',
|
|
descriptionKey: 'keybinding.action.suggestionPrev.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
defaultBindings: [kb(VK.ArrowUp, { ctrl: true, alt: true })]
|
|
},
|
|
{
|
|
id: 'suggestion-dismiss',
|
|
group: 'input',
|
|
labelKey: 'keybinding.action.suggestionDismiss',
|
|
descriptionKey: 'keybinding.action.suggestionDismiss.desc',
|
|
holdMode: false,
|
|
doublePress: false,
|
|
// "모든 액션은 기본 바인딩을 하나 이상 갖는다" 가 카탈로그 불변식이라
|
|
// (KEYBINDING_ACTIONS 불변식 테스트) 빈 배열을 기본값으로 두지 않는다.
|
|
// 평범한 Esc(전역, 수정자 없음)가 오버레이가 떠 있을 때만 반응하는
|
|
// 별도 경로로 항상 닫아 주므로, 이 바인딩은 보조 수단이다.
|
|
defaultBindings: [kb(VK.Backspace, { ctrl: true, alt: true })]
|
|
}
|
|
])
|
|
|
|
const ACTION_INDEX: ReadonlyMap<KeyBindingActionId, KeyBindingActionSpec> = new Map(
|
|
KEYBINDING_ACTIONS.map((a) => [a.id, a])
|
|
)
|
|
|
|
export function findActionSpec(id: KeyBindingActionId): KeyBindingActionSpec | null {
|
|
return ACTION_INDEX.get(id) ?? null
|
|
}
|
|
|
|
export function isKeyBindingActionId(value: string): value is KeyBindingActionId {
|
|
return ACTION_INDEX.has(value as KeyBindingActionId)
|
|
}
|
|
|
|
/** 기본 바인딩 맵 (설정 초기값 · 리셋용) */
|
|
export function createDefaultBindingMap(): KeyBindingMap {
|
|
const out = {} as KeyBindingMap
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
out[action.id] = action.defaultBindings.map((b) => ({ ...b }))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ============================================================
|
|
// 정규화 · 동일성
|
|
// ============================================================
|
|
|
|
/**
|
|
* 바인딩을 정규화한다.
|
|
*
|
|
* - 주 키가 수정자 키 자체이면 그 수정자 플래그를 끈다
|
|
* (Right Alt 단독 바인딩이 "Alt + Right Alt" 로 저장되는 것을 막는다)
|
|
* - 알 수 없는 장치는 keyboard 로 취급한다
|
|
*/
|
|
export function normalizeBinding(binding: KeyBinding): KeyBinding {
|
|
const device: BindingDevice = binding.device === 'mouse' ? 'mouse' : 'keyboard'
|
|
const next: KeyBinding = {
|
|
device,
|
|
code: binding.code,
|
|
ctrl: binding.ctrl,
|
|
alt: binding.alt,
|
|
shift: binding.shift,
|
|
meta: binding.meta
|
|
}
|
|
if (device === 'keyboard') {
|
|
const self = selfModifierOf(binding.code)
|
|
if (self !== null) next[self] = false
|
|
}
|
|
return next
|
|
}
|
|
|
|
/**
|
|
* 바인딩의 안정적 식별 문자열.
|
|
* 런타임 상태 맵(press 중 여부 · 마지막 press 시각)의 키로 쓴다 — 액션 id 가 아니라 이것으로 키잉해야
|
|
* 한 액션에 여러 바인딩이 붙어도 상태가 섞이지 않는다.
|
|
*/
|
|
export function bindingKey(binding: KeyBinding): string {
|
|
const b = normalizeBinding(binding)
|
|
const mods =
|
|
(b.ctrl ? 'C' : '') + (b.alt ? 'A' : '') + (b.shift ? 'S' : '') + (b.meta ? 'M' : '')
|
|
return `${b.device === 'mouse' ? 'm' : 'k'}:${b.code}:${mods}`
|
|
}
|
|
|
|
export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean {
|
|
return bindingKey(a) === bindingKey(b)
|
|
}
|
|
|
|
/** 수정자가 하나라도 켜져 있는지 (정규화 후 기준) */
|
|
export function hasModifier(binding: KeyBinding): boolean {
|
|
const b = normalizeBinding(binding)
|
|
return b.ctrl || b.alt || b.shift || b.meta
|
|
}
|
|
|
|
// ============================================================
|
|
// 표시 라벨
|
|
// ============================================================
|
|
|
|
export type BindingPlatform = 'darwin' | 'win32' | 'linux'
|
|
|
|
/**
|
|
* 표시용 세그먼트 하나.
|
|
* `i18nKey` 가 있으면 t() 로 번역해 쓰고, 없으면 `label` 을 그대로 쓴다.
|
|
*/
|
|
export interface BindingSegment {
|
|
label: string
|
|
i18nKey?: string
|
|
}
|
|
|
|
/**
|
|
* 바인딩 → 표시 세그먼트 배열.
|
|
*
|
|
* 수정자 표기 순서:
|
|
* macOS ⌃ ⌥ ⇧ ⌘ + key (Apple 표준)
|
|
* Win/Linux Ctrl Win Alt Shift + key
|
|
*/
|
|
export function formatBindingSegments(
|
|
binding: KeyBinding | null,
|
|
platform: BindingPlatform
|
|
): BindingSegment[] {
|
|
if (binding === null) return []
|
|
const b = normalizeBinding(binding)
|
|
const segments: BindingSegment[] = []
|
|
const mac = platform === 'darwin'
|
|
|
|
if (mac) {
|
|
if (b.ctrl) segments.push({ label: '⌃' })
|
|
if (b.alt) segments.push({ label: '⌥' })
|
|
if (b.shift) segments.push({ label: '⇧' })
|
|
if (b.meta) segments.push({ label: '⌘' })
|
|
} else {
|
|
if (b.ctrl) segments.push({ label: 'Ctrl' })
|
|
if (b.meta) segments.push({ label: 'Win' })
|
|
if (b.alt) segments.push({ label: 'Alt' })
|
|
if (b.shift) segments.push({ label: 'Shift' })
|
|
}
|
|
|
|
const entry = findKeyCatalogEntry(b.device, b.code)
|
|
if (entry === null) {
|
|
segments.push({ label: b.device === 'mouse' ? `Mouse${b.code}` : `Key${b.code}` })
|
|
} else {
|
|
segments.push({
|
|
label: mac ? (entry.macLabel ?? entry.label) : entry.label,
|
|
...(entry.labelKey === undefined ? {} : { i18nKey: entry.labelKey })
|
|
})
|
|
}
|
|
return segments
|
|
}
|
|
|
|
/** 세그먼트를 플랫폼 관례대로 결합한다. macOS 는 구분자 없음. */
|
|
export function joinBindingSegments(
|
|
segments: readonly string[],
|
|
platform: BindingPlatform
|
|
): string {
|
|
if (segments.length === 0) return ''
|
|
return platform === 'darwin' ? segments.join('') : segments.join(' + ')
|
|
}
|
|
|
|
// ============================================================
|
|
// 검증
|
|
// ============================================================
|
|
|
|
export type BindingRejectReason =
|
|
| 'unknown-key'
|
|
| 'device-disabled'
|
|
| 'modifier-required'
|
|
| 'system-reserved'
|
|
|
|
export interface BindingValidation {
|
|
valid: boolean
|
|
/** 거부 사유 (valid === true 이면 null) */
|
|
reason: BindingRejectReason | null
|
|
/** 거부 사유 i18n 키 (valid === true 이면 null) */
|
|
reasonKey: string | null
|
|
/** 허용되지만 사용자에게 알려야 하는 경고의 i18n 키 */
|
|
warningKey: string | null
|
|
}
|
|
|
|
/**
|
|
* 시스템 예약 조합.
|
|
* 문자열이 아니라 구조로 비교한다 — 문자열 비교는 수정자 입력 순서에 따라 뚫린다.
|
|
*/
|
|
const RESERVED_COMBOS: readonly KeyBinding[] = Object.freeze([
|
|
kb(0x43, { ctrl: true }), // Ctrl+C
|
|
kb(0x56, { ctrl: true }), // Ctrl+V
|
|
kb(0x58, { ctrl: true }), // Ctrl+X
|
|
kb(0x5a, { ctrl: true }), // Ctrl+Z
|
|
kb(0x41, { ctrl: true }), // Ctrl+A
|
|
kb(0x53, { ctrl: true }), // Ctrl+S
|
|
kb(0x57, { ctrl: true }), // Ctrl+W
|
|
kb(VK.F1 + 3, { alt: true }), // Alt+F4
|
|
kb(VK.Tab, { alt: true }), // Alt+Tab
|
|
kb(VK.Escape, { ctrl: true, alt: true }), // Ctrl+Alt+Esc
|
|
kb(VK.Delete, { ctrl: true, alt: true }) // Ctrl+Alt+Delete
|
|
])
|
|
|
|
const RESERVED_KEYS: ReadonlySet<string> = new Set(RESERVED_COMBOS.map(bindingKey))
|
|
|
|
/** 바인딩이 사용 가능한지 판정한다. 녹화·드롭다운 선택 양쪽에서 이 함수만 쓴다. */
|
|
export function validateBinding(binding: KeyBinding): BindingValidation {
|
|
const b = normalizeBinding(binding)
|
|
const entry = findKeyCatalogEntry(b.device, b.code)
|
|
|
|
if (entry === null) {
|
|
return {
|
|
valid: false,
|
|
reason: 'unknown-key',
|
|
reasonKey: 'keybinding.reject.unknownKey',
|
|
warningKey: null
|
|
}
|
|
}
|
|
|
|
if (entry.disabledReasonKey !== undefined) {
|
|
return {
|
|
valid: false,
|
|
reason: 'device-disabled',
|
|
reasonKey: entry.disabledReasonKey,
|
|
warningKey: null
|
|
}
|
|
}
|
|
|
|
if (RESERVED_KEYS.has(bindingKey(b))) {
|
|
return {
|
|
valid: false,
|
|
reason: 'system-reserved',
|
|
reasonKey: 'keybinding.reject.systemReserved',
|
|
warningKey: null
|
|
}
|
|
}
|
|
|
|
if (entry.requiresModifier && !hasModifier(b)) {
|
|
return {
|
|
valid: false,
|
|
reason: 'modifier-required',
|
|
reasonKey: 'keybinding.reject.modifierRequired',
|
|
warningKey: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
reason: null,
|
|
reasonKey: null,
|
|
warningKey: entry.passthroughWarningKey ?? null
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 충돌 판정
|
|
// ============================================================
|
|
|
|
export interface BindingConflict {
|
|
/** 이미 같은 바인딩을 점유한 액션 */
|
|
actionId: KeyBindingActionId
|
|
}
|
|
|
|
/**
|
|
* 다른 액션이 이미 같은 바인딩을 쓰고 있는지 검사한다.
|
|
*
|
|
* dictation / hands-free 처럼 홀드와 더블프레스로 구분되는 쌍은 충돌이 아니다 —
|
|
* 같은 키를 공유하는 것이 기본 설계다.
|
|
*/
|
|
export function detectBindingConflicts(
|
|
targetAction: KeyBindingActionId,
|
|
binding: KeyBinding,
|
|
map: Readonly<KeyBindingMap>
|
|
): BindingConflict[] {
|
|
const key = bindingKey(binding)
|
|
const target = findActionSpec(targetAction)
|
|
const conflicts: BindingConflict[] = []
|
|
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
if (action.id === targetAction) continue
|
|
// 홀드 vs 더블프레스는 같은 키를 공유해도 런타임에서 구분된다.
|
|
if (target !== null && target.doublePress !== action.doublePress) continue
|
|
|
|
const bindings = map[action.id] ?? []
|
|
if (bindings.some((b) => bindingKey(b) === key)) {
|
|
conflicts.push({ actionId: action.id })
|
|
}
|
|
}
|
|
return conflicts
|
|
}
|
|
|
|
/** 저장된 전체 단축키 설정에서 사용 불가·중복 바인딩을 수집한 결과. */
|
|
export interface KeyBindingAuditIssue {
|
|
kind: 'invalid' | 'conflict'
|
|
actionId: KeyBindingActionId
|
|
bindingIndex: number
|
|
binding: KeyBinding
|
|
reasonKey: string | null
|
|
conflictActionIds: KeyBindingActionId[]
|
|
}
|
|
|
|
/**
|
|
* 전체 바인딩 맵을 한 번에 검수한다.
|
|
*
|
|
* hold/double-press 예외는 detectBindingConflicts()의 기존 계약을 그대로 따른다.
|
|
*/
|
|
export function auditKeyBindingMap(map: Readonly<KeyBindingMap>): KeyBindingAuditIssue[] {
|
|
const issues: KeyBindingAuditIssue[] = []
|
|
const reportedPairs = new Set<string>()
|
|
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
const bindings = map[action.id] ?? []
|
|
for (const [bindingIndex, binding] of bindings.entries()) {
|
|
const validation = validateBinding(binding)
|
|
if (!validation.valid) {
|
|
issues.push({
|
|
kind: 'invalid',
|
|
actionId: action.id,
|
|
bindingIndex,
|
|
binding: { ...binding },
|
|
reasonKey: validation.reasonKey,
|
|
conflictActionIds: []
|
|
})
|
|
}
|
|
|
|
const conflictActionIds = detectBindingConflicts(action.id, binding, map)
|
|
.map((conflict) => conflict.actionId)
|
|
.filter((otherActionId) => action.id.localeCompare(otherActionId) < 0)
|
|
.filter((otherActionId) => {
|
|
const pairKey = `${bindingKey(binding)}:${action.id}:${otherActionId}`
|
|
if (reportedPairs.has(pairKey)) return false
|
|
reportedPairs.add(pairKey)
|
|
return true
|
|
})
|
|
.sort((a, b) => a.localeCompare(b))
|
|
|
|
if (conflictActionIds.length > 0) {
|
|
issues.push({
|
|
kind: 'conflict',
|
|
actionId: action.id,
|
|
bindingIndex,
|
|
binding: { ...binding },
|
|
reasonKey: null,
|
|
conflictActionIds
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
// ============================================================
|
|
// 검색 (드롭다운 필터)
|
|
// ============================================================
|
|
|
|
/**
|
|
* 카탈로그 검색.
|
|
*
|
|
* `localizedLabels` 로 번역된 이름을 넘기면 함께 매칭한다(마우스 버튼 등).
|
|
* 키는 `${device}:${code}`.
|
|
*/
|
|
export function searchKeyCatalog(
|
|
query: string,
|
|
localizedLabels: Readonly<Record<string, string>> = {}
|
|
): KeyCatalogEntry[] {
|
|
const q = query.trim().toLowerCase()
|
|
if (q === '') return [...KEY_CATALOG]
|
|
|
|
return KEY_CATALOG.filter((entry) => {
|
|
if (entry.label.toLowerCase().includes(q)) return true
|
|
if (entry.aliases.some((a) => a.includes(q))) return true
|
|
const localized = localizedLabels[`${entry.device}:${entry.code}`]
|
|
if (localized !== undefined && localized.toLowerCase().includes(q)) return true
|
|
return false
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// IPC 계약
|
|
// ============================================================
|
|
|
|
/** `keybinding:setBindings` 파라미터 */
|
|
export interface SetKeyBindingsParams {
|
|
actionId: KeyBindingActionId
|
|
bindings: KeyBinding[]
|
|
}
|
|
|
|
/** `keybinding:resetAction` 파라미터 */
|
|
export interface ResetKeyBindingParams {
|
|
actionId: KeyBindingActionId
|
|
}
|
|
|
|
/** `keybinding:validate` 파라미터 */
|
|
export interface ValidateKeyBindingParams {
|
|
actionId: KeyBindingActionId
|
|
binding: KeyBinding
|
|
}
|
|
|
|
/** `keybinding:validate` 결과 — 유효성 + 액션 간 충돌 */
|
|
export interface KeyBindingValidationResult {
|
|
validation: BindingValidation
|
|
conflicts: BindingConflict[]
|
|
}
|
|
|
|
/** `keybinding:triggered` 페이로드 */
|
|
export interface KeyBindingTriggeredEvent {
|
|
actionId: KeyBindingActionId
|
|
type: 'pressed' | 'released'
|
|
isDoublePress: boolean
|
|
}
|
|
|
|
/** `keybinding:changed` 페이로드 */
|
|
export interface KeyBindingChangedEvent {
|
|
map: KeyBindingMap
|
|
}
|
|
|
|
// ============================================================
|
|
// 역직렬화 가드 (저장소 · IPC 경계)
|
|
// ============================================================
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
/** 임의의 값이 KeyBinding 형태인지 검사한다. electron-store 는 스키마 검증을 하지 않으므로 필요하다. */
|
|
export function isKeyBinding(value: unknown): value is KeyBinding {
|
|
if (!isRecord(value)) return false
|
|
if (value.device !== 'keyboard' && value.device !== 'mouse') return false
|
|
if (typeof value.code !== 'number' || !Number.isFinite(value.code)) return false
|
|
return (
|
|
typeof value.ctrl === 'boolean' &&
|
|
typeof value.alt === 'boolean' &&
|
|
typeof value.shift === 'boolean' &&
|
|
typeof value.meta === 'boolean'
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 저장된 값에서 바인딩 목록을 복원한다. 손상된 항목은 조용히 버린다.
|
|
* 결과가 비면 호출부가 기본값으로 되돌릴 수 있게 빈 배열을 반환한다.
|
|
*/
|
|
export function parseBindingList(value: unknown): KeyBinding[] {
|
|
if (!Array.isArray(value)) return []
|
|
const out: KeyBinding[] = []
|
|
const seen = new Set<string>()
|
|
for (const item of value) {
|
|
if (!isKeyBinding(item)) continue
|
|
const normalized = normalizeBinding(item)
|
|
const key = bindingKey(normalized)
|
|
if (seen.has(key)) continue
|
|
seen.add(key)
|
|
out.push(normalized)
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* 저장된 전체 맵을 복원한다 (옛 의미). 누락·손상 액션 **과 명시적 빈 목록** 을 기본값으로 채운다.
|
|
*
|
|
* @deprecated 저장소를 읽는 경로는 `decodeKeyBindingMap` 을 쓴다. 이 함수는 사용자가 지운
|
|
* 액션(`[]`)을 기본값으로 되살리므로, 저장값을 읽고 다시 쓰는 곳에서 쓰면 지운 단축키가
|
|
* 되돌아온다. 옛 호출부 호환을 위해서만 남긴다.
|
|
*/
|
|
export function parseBindingMap(value: unknown): KeyBindingMap {
|
|
const defaults = createDefaultBindingMap()
|
|
if (!isRecord(value)) return defaults
|
|
|
|
const out = {} as KeyBindingMap
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
const parsed = parseBindingList(value[action.id])
|
|
out[action.id] = parsed.length > 0 ? parsed : defaults[action.id]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ============================================================
|
|
// 저장 코덱 — 저장된 keyBindings 를 읽는 단일 정본
|
|
// ============================================================
|
|
|
|
/** 액션 하나의 저장값을 해석한 결과 */
|
|
type StoredListState =
|
|
/** 저장값 그대로 쓸 수 있다 */
|
|
| 'intact'
|
|
/** 유효 항목은 있지만 손상·중복 항목을 버렸거나 정규화로 모양이 바뀌었다 */
|
|
| 'normalized'
|
|
/** 누락·배열 아님·전부 손상 — 기본값으로 복구했다 */
|
|
| 'repaired'
|
|
|
|
function sameStoredShape(stored: KeyBinding, decoded: KeyBinding): boolean {
|
|
return (
|
|
stored.device === decoded.device &&
|
|
stored.code === decoded.code &&
|
|
stored.ctrl === decoded.ctrl &&
|
|
stored.alt === decoded.alt &&
|
|
stored.shift === decoded.shift &&
|
|
stored.meta === decoded.meta
|
|
)
|
|
}
|
|
|
|
function decodeBindingList(
|
|
value: unknown,
|
|
fallback: readonly KeyBinding[]
|
|
): { bindings: KeyBinding[]; state: StoredListState } {
|
|
const repaired = (): { bindings: KeyBinding[]; state: StoredListState } => ({
|
|
bindings: fallback.map((b) => ({ ...b })),
|
|
state: 'repaired'
|
|
})
|
|
if (!Array.isArray(value)) return repaired()
|
|
// 명시적 빈 목록 = 사용자가 이 액션의 단축키를 모두 지웠다. 기본값으로 되살리지 않는다.
|
|
if (value.length === 0) return { bindings: [], state: 'intact' }
|
|
|
|
const bindings = parseBindingList(value)
|
|
// 비어 있지 않았는데 쓸 만한 항목이 하나도 없다 = 손상. 기본값으로 복구한다.
|
|
if (bindings.length === 0) return repaired()
|
|
|
|
const intact =
|
|
bindings.length === value.length &&
|
|
bindings.every((binding, index) => {
|
|
const stored: unknown = value[index]
|
|
return isKeyBinding(stored) && sameStoredShape(stored, binding)
|
|
})
|
|
return { bindings, state: intact ? 'intact' : 'normalized' }
|
|
}
|
|
|
|
/** `decodeKeyBindingMap` 결과 */
|
|
export interface DecodedKeyBindingMap {
|
|
/** 모든 액션 키를 가진 정규화된 맵. 명시적 빈 목록은 빈 목록 그대로다. */
|
|
map: KeyBindingMap
|
|
/** 저장값이 없거나(새 액션 포함) 손상되어 기본값으로 채운 액션 */
|
|
repairedActions: KeyBindingActionId[]
|
|
/** 결과가 저장된 모양과 달라 다시 써야 하는지 (복구 · 손상 항목 제거 · 정규화) */
|
|
needsRewrite: boolean
|
|
}
|
|
|
|
/**
|
|
* 저장된 keyBindings 를 엄격하게 해석한다 — 저장소를 읽는 모든 경로(설정 로딩 · IPC ·
|
|
* 마이그레이션 · 후킹 서비스)의 단일 정본.
|
|
*
|
|
* - 명시적 빈 목록(`[]`)은 "이 액션은 단축키 없음" 으로 보존한다.
|
|
* - 누락 · 배열 아님 · 비어 있지 않은데 유효 항목이 없는 액션만 기본값으로 복구한다.
|
|
* - 유효 항목은 정규화하고 중복을 없앤다. 알 수 없는 액션 키는 버린다.
|
|
*/
|
|
export function decodeKeyBindingMap(value: unknown): DecodedKeyBindingMap {
|
|
const record = isRecord(value) && !Array.isArray(value) ? value : null
|
|
const map = {} as KeyBindingMap
|
|
const repairedActions: KeyBindingActionId[] = []
|
|
let needsRewrite = record === null
|
|
|
|
for (const action of KEYBINDING_ACTIONS) {
|
|
const decoded = decodeBindingList(record?.[action.id], action.defaultBindings)
|
|
map[action.id] = decoded.bindings
|
|
if (decoded.state === 'repaired') repairedActions.push(action.id)
|
|
if (decoded.state !== 'intact') needsRewrite = true
|
|
}
|
|
return { map, repairedActions, needsRewrite }
|
|
}
|