feat: 新增 hotkeys 子包,统一维护全局快捷键

This commit is contained in:
Hooray
2026-06-24 03:13:24 +08:00
parent ee62670209
commit 5bbe23df3e
118 changed files with 1524 additions and 3785 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@fantastic-admin/hotkeys",
"type": "module",
"version": "0.0.0",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"vue": "catalog:"
},
"dependencies": {
"@fantastic-admin/settings": "workspace:*",
"hotkeys-js": "catalog:",
"type-fest": "catalog:"
}
}
+148
View File
@@ -0,0 +1,148 @@
import type { MaybeRefOrGetter } from 'vue'
import type { BuiltinHotkeyId } from './registry'
import type {
HotkeyBinding,
HotkeyContextBase,
HotkeyHandler,
HotkeyHandlersMap,
HotkeyIdMap,
HotkeyIdValue,
} from './types'
import hotkeys from 'hotkeys-js'
import { onUnmounted, toValue, watch } from 'vue'
import {
BUILTIN_HOTKEY_ID,
builtinGlobalHotkeyBindings,
builtinMenuSearchHotkeyBindings,
} from './registry'
type EmptyHotkeyIdMap = Record<never, never>
export type HotkeyId<TExtId extends HotkeyIdMap = EmptyHotkeyIdMap> = BuiltinHotkeyId | HotkeyIdValue<TExtId>
export interface CreateHotkeysOptions<
TExtId extends HotkeyIdMap = EmptyHotkeyIdMap,
TContext extends HotkeyContextBase = HotkeyContextBase,
> {
extendIds?: TExtId
extendGlobalHotkeyBindings?: readonly HotkeyBinding<HotkeyId<TExtId>, TContext>[]
extendScopedHotkeyBindings?: readonly HotkeyBinding<HotkeyId<TExtId>, TContext>[]
getContext: () => TContext
}
export function createHotkeys<
const TExtId extends HotkeyIdMap = EmptyHotkeyIdMap,
TContext extends HotkeyContextBase = HotkeyContextBase,
>(options: CreateHotkeysOptions<TExtId, TContext>) {
type Id = HotkeyId<TExtId>
const HOTKEY_ID = {
...BUILTIN_HOTKEY_ID,
...options.extendIds,
} as typeof BUILTIN_HOTKEY_ID & TExtId
const hotkeyIds = Object.values(HOTKEY_ID) as Id[]
const extendGlobalHotkeyBindings = options.extendGlobalHotkeyBindings ?? []
const extendScopedHotkeyBindings = options.extendScopedHotkeyBindings ?? []
const globalHotkeyBindings = [
...builtinGlobalHotkeyBindings,
...extendGlobalHotkeyBindings,
] as HotkeyBinding<Id, TContext>[]
const menuSearchHotkeyBindings = [
...builtinMenuSearchHotkeyBindings,
] as HotkeyBinding<Id, TContext>[]
const hotkeyBindings = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
] as HotkeyBinding<Id, TContext>[]
function useHotkey(id: Id, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
const hotkeyContext = options.getContext()
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
function useHotkeyBindings(handlers: HotkeyHandlersMap<Id>, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
return {
HOTKEY_ID,
hotkeyIds,
globalHotkeyBindings,
menuSearchHotkeyBindings,
hotkeyBindings,
useHotkey,
useHotkeyBindings,
}
}
+20
View File
@@ -0,0 +1,20 @@
export { createHotkeys } from './createHotkeys'
export type { CreateHotkeysOptions, HotkeyId } from './createHotkeys'
export {
BUILTIN_HOTKEY_ID,
builtinGlobalHotkeyBindings,
builtinMenuSearchHotkeyBindings,
} from './registry'
export type { BuiltinHotkeyId } from './registry'
export type {
HotkeyBinding,
HotkeyContextBase,
HotkeyDisplayKeys,
HotkeyHandler,
HotkeyHandlerArgs,
HotkeyHandlersMap,
HotkeyHelpGroup,
HotkeyHelpMeta,
HotkeyIdMap,
HotkeyIdValue,
} from './types'
+188
View File
@@ -0,0 +1,188 @@
import type { HotkeyBinding, HotkeyContextBase } from './types'
export const BUILTIN_HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
} as const
export type BuiltinHotkeyId = typeof BUILTIN_HOTKEY_ID[keyof typeof BUILTIN_HOTKEY_ID]
/**
* Hotkeys registry 维护约定
*
* 1. 新增框架内建快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const builtinGlobalHotkeyBindings: HotkeyBinding<BuiltinHotkeyId, HotkeyContextBase>[] = [
{
id: BUILTIN_HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: BUILTIN_HOTKEY_ID.pageReload,
keys: ['f5'],
},
]
export const builtinMenuSearchHotkeyBindings: HotkeyBinding<BuiltinHotkeyId, HotkeyContextBase>[] = [
{
id: BUILTIN_HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: BUILTIN_HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: BUILTIN_HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: BUILTIN_HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
+45
View File
@@ -0,0 +1,45 @@
import type { SettingsOptions } from '@fantastic-admin/settings'
import type { RequiredDeep } from 'type-fest'
export type HotkeyIdMap = Record<string, string>
export type HotkeyIdValue<T extends HotkeyIdMap> = T[keyof T]
export interface HotkeyContextBase {
settings: RequiredDeep<SettingsOptions>
}
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | (string & {})
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta<TContext extends HotkeyContextBase = HotkeyContextBase> {
group: HotkeyHelpGroup
groupTitleKey?: string
titleKey: string
order?: number
visible?: (ctx: TContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding<
TId extends string = string,
TContext extends HotkeyContextBase = HotkeyContextBase,
> {
id: TId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: TContext) => boolean
help?: HotkeyHelpMeta<TContext>
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap<TId extends string = string> = Partial<Record<TId, HotkeyHandler>>
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client", "node"]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts"
]
}