- 设置页新增「键盘快捷键」分类,可自定义快捷键绑定 - 支持快速滚动(Home/End/PageUp/PageDown)与导航跳转(Ctrl/Cmd+数字) - 快捷键绑定与开关按动作独立持久化,默认关闭 - 新增 ui 包 keybinding 组件与工具(KeybindingModal/KeybindingChips 等)
This commit is contained in:
1 parent
47bdb1e79c
commit
2e92887eb2
24 files changed
+2432
-5
No files matched your search
@@ -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(' + ')
|
||||
}
|
||||
Reference in new issue
Block a user