mirror of
https://github.com/fantastic-admin/basic.git
synced 2026-10-07 16:48:39 +08:00
feat: 新增 hotkeys 子包,统一维护全局快捷键
This commit is contained in:
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
@@ -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>>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user