feat: 新增键盘快捷键设置
Sync source to CNB / Sync Git ref (push) Skipped

- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定
- 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字)
- 快捷键绑定与开关按动作独立持久化,默认关闭
- 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
This commit is contained in:
AxTps committed 2026-10-11 11:14:46 +08:00
1 parent 47bdb1e79c
commit 2e92887eb2
24 files changed
+2432 -5

No files matched your search

+229
View File
@@ -0,0 +1,229 @@
/**
* The device a binding was recorded from. Keyboard bindings are matched against
* `KeyboardEvent`, mouse bindings against `MouseEvent` and `WheelEvent`.
*/
export type KeyBindingDevice = 'keyboard' | 'mouse'
/**
* A single shortcut. `mod` is the primary modifier held down: the launcher has
* always treated Ctrl and Cmd as one and the same, so a binding recorded on one
* platform keeps working on the other.
*/
export interface KeyBinding {
device: KeyBindingDevice
/** A `KeyboardEvent.code`, or one of the mouse codes below. */
code: string
mod: boolean
shift: boolean
alt: boolean
}
/** `MouseEvent.button`, in the order browsers report it. */
export const MOUSE_BUTTON_CODES = ['Mouse0', 'Mouse1', 'Mouse2', 'Mouse3', 'Mouse4'] as const
export const WHEEL_CODES = ['WheelUp', 'WheelDown'] as const
const MODIFIER_CODES = new Set([
'ControlLeft',
'ControlRight',
'ShiftLeft',
'ShiftRight',
'AltLeft',
'AltRight',
'MetaLeft',
'MetaRight',
'CapsLock',
])
/**
* Codes that may be bound on their own. Anything else has to carry a modifier,
* so a shortcut cannot swallow ordinary typing or list navigation.
*/
const MODIFIER_FREE_CODES = new Set<string>([
'Home',
'End',
'PageUp',
'PageDown',
...Array.from({ length: 24 }, (_, index) => `F${index + 1}`),
...MOUSE_BUTTON_CODES.filter((code) => code !== 'Mouse0'),
...WHEEL_CODES,
])
/**
* Codes that always need a modifier, whatever else is allowed. A bare left
* click is how the whole interface is operated, so it can never be a shortcut.
*/
const MODIFIER_REQUIRED_CODES = new Set<string>(['Mouse0'])
/** A combination someone else already answers to. */
export interface BindingConflict {
id: string
label: string
binding: KeyBinding
/** The shell or the operating system owns it rather than another action. */
reserved?: boolean
}
export type BindingIssue =
{ kind: 'conflict'; label: string; reserved: boolean } | { kind: 'needs-modifier' }
export interface KeybindingLabels {
mod: string
shift: string
alt: string
/** Keyed by mouse code, e.g. `Mouse0`. */
mouseButton: Record<string, string>
wheelUp: string
wheelDown: string
}
export function isModifierCode(code: string): boolean {
return MODIFIER_CODES.has(code)
}
export function isMouseCode(code: string): boolean {
return code.startsWith('Mouse') || (WHEEL_CODES as readonly string[]).includes(code)
}
/**
* Canonical form used to compare bindings, so two ways of recording the same
* combination still collide.
*/
export function bindingKey(binding: KeyBinding): string {
const modifiers = [binding.mod && 'mod', binding.alt && 'alt', binding.shift && 'shift']
.filter(Boolean)
.join('+')
return `${binding.device}|${binding.code}|${modifiers}`
}
export function bindingsEqual(a: KeyBinding, b: KeyBinding): boolean {
return bindingKey(a) === bindingKey(b)
}
function eventModifiers(event: MouseEvent | KeyboardEvent | WheelEvent) {
return {
mod: event.ctrlKey || event.metaKey,
shift: event.shiftKey,
alt: event.altKey,
}
}
/**
* The combination an event represents, or null while only modifiers are held -
* a lone Ctrl is not a shortcut.
*/
export function bindingFromKeyboardEvent(event: KeyboardEvent): KeyBinding | null {
if (event.repeat || isModifierCode(event.code)) return null
return { device: 'keyboard', code: event.code, ...eventModifiers(event) }
}
export function bindingFromMouseEvent(event: MouseEvent): KeyBinding | null {
const code = `Mouse${event.button}`
if (!(MOUSE_BUTTON_CODES as readonly string[]).includes(code)) return null
return { device: 'mouse', code, ...eventModifiers(event) }
}
export function bindingFromWheelEvent(event: WheelEvent): KeyBinding | null {
if (event.deltaY === 0) return null
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return { device: 'mouse', code, ...eventModifiers(event) }
}
function modifiersMatch(binding: KeyBinding, event: MouseEvent | KeyboardEvent | WheelEvent) {
const { mod, shift, alt } = eventModifiers(event)
return binding.mod === mod && binding.shift === shift && binding.alt === alt
}
export function bindingMatchesKeyboardEvent(binding: KeyBinding, event: KeyboardEvent): boolean {
return (
binding.device === 'keyboard' && binding.code === event.code && modifiersMatch(binding, event)
)
}
export function bindingMatchesMouseEvent(binding: KeyBinding, event: MouseEvent): boolean {
return (
binding.device === 'mouse' &&
binding.code === `Mouse${event.button}` &&
modifiersMatch(binding, event)
)
}
export function bindingMatchesWheelEvent(binding: KeyBinding, event: WheelEvent): boolean {
if (binding.device !== 'mouse' || event.deltaY === 0) return false
const code = event.deltaY < 0 ? 'WheelUp' : 'WheelDown'
return binding.code === code && modifiersMatch(binding, event)
}
/**
* Whether a combination may be saved, ignoring who it belongs to. Returns the
* problem to show instead of the binding when it may not.
*/
export function validateBinding(
binding: KeyBinding,
conflicts: BindingConflict[],
): BindingIssue | null {
const unmodified = !binding.mod && !binding.shift && !binding.alt
if (unmodified && MODIFIER_REQUIRED_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
if (unmodified && !MODIFIER_FREE_CODES.has(binding.code)) {
return { kind: 'needs-modifier' }
}
const conflict = conflicts.find((entry) => bindingsEqual(entry.binding, binding))
if (conflict) {
return { kind: 'conflict', label: conflict.label, reserved: conflict.reserved ?? false }
}
return null
}
const CODE_LABELS: Record<string, string> = {
ArrowUp: '\u2191',
ArrowDown: '\u2193',
ArrowLeft: '\u2190',
ArrowRight: '\u2192',
PageUp: 'Page Up',
PageDown: 'Page Down',
Escape: 'Esc',
Space: 'Space',
Insert: 'Insert',
Delete: 'Delete',
Comma: ',',
Period: '.',
Slash: '/',
Semicolon: ';',
Quote: "'",
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Minus: '-',
Equal: '=',
Backquote: '`',
}
/** How a single code reads on a key cap. */
export function keyCodeLabel(code: string): string {
if (code.startsWith('Key')) return code.slice(3)
if (code.startsWith('Digit')) return code.slice(5)
if (code.startsWith('Numpad')) return `Num ${code.slice(6)}`
return CODE_LABELS[code] ?? code
}
/** The combination split into the pieces a key cap is drawn for. */
export function bindingParts(binding: KeyBinding, labels: KeybindingLabels): string[] {
const parts: string[] = []
if (binding.mod) parts.push(labels.mod)
if (binding.alt) parts.push(labels.alt)
if (binding.shift) parts.push(labels.shift)
if (binding.code === 'WheelUp') parts.push(labels.wheelUp)
else if (binding.code === 'WheelDown') parts.push(labels.wheelDown)
else if (binding.device === 'mouse') parts.push(labels.mouseButton[binding.code] ?? binding.code)
else parts.push(keyCodeLabel(binding.code))
return parts
}
export function bindingText(binding: KeyBinding, labels: KeybindingLabels): string {
return bindingParts(binding, labels).join(' + ')
}