♻️ refactor: migrate shortcuts to preferences

This commit is contained in:
beyondkmp 2025-10-30 11:28:41 +08:00
parent cf008ca22e
commit 34affb4533
20 changed files with 963 additions and 538 deletions

View File

@ -373,6 +373,8 @@ export interface PreferenceSchemas {
'shortcut.chat.clear': Record<string, unknown>
// redux/shortcuts/shortcuts.copy_last_message
'shortcut.chat.copy_last_message': Record<string, unknown>
// redux/shortcuts/shortcuts.edit_last_user_message
'shortcut.chat.edit_last_user_message': Record<string, unknown>
// redux/shortcuts/shortcuts.search_message_in_chat
'shortcut.chat.search_message': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_new_context
@ -383,6 +385,10 @@ export interface PreferenceSchemas {
'shortcut.selection.toggle_enabled': Record<string, unknown>
// redux/shortcuts/shortcuts.new_topic
'shortcut.topic.new': Record<string, unknown>
// redux/shortcuts/shortcuts.rename_topic
'shortcut.topic.rename': Record<string, unknown>
// redux/shortcuts/shortcuts.toggle_show_topics
'shortcut.topic.toggle_show_topics': Record<string, unknown>
// redux/settings/enableTopicNaming
'topic.naming.enabled': boolean
// redux/settings/topicNamingPrompt
@ -638,6 +644,12 @@ export const DefaultPreferences: PreferenceSchemas = {
key: ['CommandOrControl', 'Shift', 'C'],
system: false
},
'shortcut.chat.edit_last_user_message': {
editable: true,
enabled: false,
key: ['CommandOrControl', 'Shift', 'E'],
system: false
},
'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false },
'shortcut.chat.toggle_new_context': {
editable: true,
@ -648,6 +660,18 @@ export const DefaultPreferences: PreferenceSchemas = {
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
'shortcut.topic.rename': {
editable: true,
enabled: true,
key: ['CommandOrControl', 'T'],
system: false
},
'shortcut.topic.toggle_show_topics': {
editable: true,
enabled: true,
key: ['CommandOrControl', ']'],
system: false
},
'topic.naming.enabled': true,
'topic.naming_prompt': '',
'topic.position': 'left',

View File

@ -0,0 +1,148 @@
import type { ShortcutCategory, ShortcutDefinition } from './types'
export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
// ==================== 应用级快捷键 ====================
{
key: 'shortcut.app.show_main_window',
defaultKey: ['CommandOrControl', 'Shift', 'A'],
scope: 'main',
category: 'app',
persistOnBlur: true
},
{
key: 'shortcut.app.show_mini_window',
defaultKey: ['CommandOrControl', 'E'],
scope: 'main',
category: 'app',
persistOnBlur: true,
enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled')
},
{
key: 'shortcut.app.show_settings',
defaultKey: ['CommandOrControl', ','],
scope: 'both',
category: 'app'
},
{
key: 'shortcut.app.toggle_show_assistants',
defaultKey: ['CommandOrControl', '['],
scope: 'renderer',
category: 'app'
},
{
key: 'shortcut.app.exit_fullscreen',
defaultKey: ['Escape'],
scope: 'renderer',
category: 'app'
},
{
key: 'shortcut.app.zoom_in',
defaultKey: ['CommandOrControl', '='],
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numadd']]
},
{
key: 'shortcut.app.zoom_out',
defaultKey: ['CommandOrControl', '-'],
scope: 'main',
category: 'app',
variants: [['CommandOrControl', 'numsub']]
},
{
key: 'shortcut.app.zoom_reset',
defaultKey: ['CommandOrControl', '0'],
scope: 'main',
category: 'app'
},
{
key: 'shortcut.app.search_message',
defaultKey: ['CommandOrControl', 'Shift', 'F'],
scope: 'renderer',
category: 'app'
},
// ==================== 聊天相关快捷键 ====================
{
key: 'shortcut.chat.clear',
defaultKey: ['CommandOrControl', 'L'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.search_message',
defaultKey: ['CommandOrControl', 'F'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.toggle_new_context',
defaultKey: ['CommandOrControl', 'K'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.copy_last_message',
defaultKey: ['CommandOrControl', 'Shift', 'C'],
scope: 'renderer',
category: 'chat'
},
{
key: 'shortcut.chat.edit_last_user_message',
defaultKey: ['CommandOrControl', 'Shift', 'E'],
scope: 'renderer',
category: 'chat'
},
// ==================== 话题管理快捷键 ====================
{
key: 'shortcut.topic.new',
defaultKey: ['CommandOrControl', 'N'],
scope: 'renderer',
category: 'topic'
},
{
key: 'shortcut.topic.rename',
defaultKey: ['CommandOrControl', 'T'],
scope: 'renderer',
category: 'topic'
},
{
key: 'shortcut.topic.toggle_show_topics',
defaultKey: ['CommandOrControl', ']'],
scope: 'renderer',
category: 'topic'
},
// ==================== 划词助手快捷键 ====================
{
key: 'shortcut.selection.toggle_enabled',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
},
{
key: 'shortcut.selection.get_text',
defaultKey: [],
scope: 'main',
category: 'selection',
persistOnBlur: true
}
] as const
export const getShortcutsByCategory = () => {
const groups: Record<ShortcutCategory, ShortcutDefinition[]> = {
app: [],
chat: [],
topic: [],
selection: []
}
SHORTCUT_DEFINITIONS.forEach((definition) => {
groups[definition.category].push(definition)
})
return groups
}
export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => {
return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key)
}

View File

@ -0,0 +1,40 @@
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes'
import type { BrowserWindow } from 'electron'
export type ShortcutScope = 'main' | 'renderer' | 'both'
export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection'
export type ShortcutPreferenceKey = Extract<PreferenceKeyType, `shortcut.${string}`>
export type GetPreferenceFn = <K extends PreferenceKeyType>(key: K) => PreferenceDefaultScopeType[K]
export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean
export interface ShortcutDefinition {
key: ShortcutPreferenceKey
defaultKey: string[]
scope: ShortcutScope
category: ShortcutCategory
persistOnBlur?: boolean
variants?: string[][]
enabledWhen?: ShortcutEnabledPredicate
}
export interface ShortcutPreferenceValue {
binding: string[]
rawBinding: string[]
hasCustomBinding: boolean
enabled: boolean
editable: boolean
system: boolean
}
export interface ShortcutRuntimeConfig extends ShortcutDefinition {
binding: string[]
enabled: boolean
editable: boolean
system: boolean
}
export type ShortcutHandler = (window?: BrowserWindow) => void | Promise<void>

View File

@ -0,0 +1,136 @@
const modifierKeys = ['CommandOrControl', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Command']
const specialSingleKeys = ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
export const convertKeyToAccelerator = (key: string): string => {
const keyMap: Record<string, string> = {
Command: 'CommandOrControl',
Cmd: 'CommandOrControl',
Control: 'Ctrl',
Meta: 'Meta',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
AltGraph: 'AltGr',
Slash: '/',
Semicolon: ';',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Quote: "'",
Comma: ',',
Minus: '-',
Equal: '='
}
return keyMap[key] || key
}
export const convertAcceleratorToHotkey = (accelerator: string[]): string => {
return accelerator
.map((key) => {
switch (key.toLowerCase()) {
case 'commandorcontrol':
return 'mod'
case 'command':
case 'cmd':
return 'meta'
case 'control':
case 'ctrl':
return 'ctrl'
case 'alt':
return 'alt'
case 'shift':
return 'shift'
case 'meta':
return 'meta'
default:
return key.toLowerCase()
}
})
.join('+')
}
export const formatShortcutDisplay = (keys: string[], isMac: boolean): string => {
return keys
.map((key) => {
switch (key.toLowerCase()) {
case 'ctrl':
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'command':
case 'cmd':
return isMac ? '⌘' : 'Win'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'meta':
return isMac ? '⌘' : 'Win'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join(isMac ? '' : '+')
}
export const isValidShortcut = (keys: string[]): boolean => {
if (!keys.length) {
return false
}
const hasModifier = keys.some((key) => modifierKeys.includes(key))
const isSpecialKey = keys.length === 1 && specialSingleKeys.includes(keys[0])
return hasModifier || isSpecialKey
}
const ensureArray = (value: unknown): string[] => {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string')
}
return []
}
const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
export const getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => {
const fallback = DefaultPreferences.default[definition.key] as PreferenceShortcutType
const rawBinding = ensureArray(fallback?.key)
const binding = rawBinding.length ? rawBinding : definition.defaultKey
return {
binding,
rawBinding: binding,
hasCustomBinding: false,
enabled: ensureBoolean(fallback?.enabled, true),
editable: ensureBoolean(fallback?.editable, true),
system: ensureBoolean(fallback?.system, false)
}
}
export const coerceShortcutPreference = (
definition: ShortcutDefinition,
value?: PreferenceShortcutType | null
): ShortcutPreferenceValue => {
const fallback = getDefaultShortcutPreference(definition)
const hasCustomBinding = Array.isArray((value as PreferenceShortcutType | undefined)?.key)
const rawBinding = hasCustomBinding ? ensureArray((value as PreferenceShortcutType).key) : fallback.binding
const binding = rawBinding.length > 0 ? rawBinding : fallback.binding
return {
binding,
rawBinding,
hasCustomBinding,
enabled: ensureBoolean(value?.enabled, fallback.enabled),
editable: ensureBoolean(value?.editable, fallback.editable),
system: ensureBoolean(value?.system, fallback.system)
}
}
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import type { ShortcutDefinition, ShortcutPreferenceValue } from './types'

View File

@ -29,7 +29,7 @@ import {
setupAppImageDeepLink
} from './services/ProtocolClient'
import selectionService, { initSelectionService } from './services/SelectionService'
import { registerShortcuts } from './services/ShortcutService'
import { shortcutService } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService'
@ -216,7 +216,7 @@ if (!app.requestSingleInstanceLock()) {
}
})
registerShortcuts(mainWindow)
shortcutService.registerForWindow(mainWindow)
registerIpc(mainWindow, app)
replaceDevtoolsFont(mainWindow)

View File

@ -21,7 +21,6 @@ import type {
OcrProvider,
PluginError,
Provider,
Shortcut,
SupportedOcrFile
} from '@types'
import checkDiskSpace from 'check-disk-space'
@ -35,7 +34,6 @@ import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'
import { codeToolsService } from './services/CodeToolsService'
import { configManager } from './services/ConfigManager'
import CopilotService from './services/CopilotService'
import DxtService from './services/DxtService'
import { ExportService } from './services/ExportService'
@ -56,7 +54,6 @@ import { pythonService } from './services/PythonService'
import { FileServiceManager } from './services/remotefile/FileServiceManager'
import { searchService } from './services/SearchService'
import { SelectionService } from './services/SelectionService'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import {
addEndMessage,
addStreamMessage,
@ -581,16 +578,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
})
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))

View File

@ -1,298 +1,310 @@
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { handleZoomFactor } from '@main/utils/zoom'
import type { Shortcut } from '@types'
import type { PreferenceDefaultScopeType } from '@shared/data/preference/preferenceTypes'
import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type {
ShortcutDefinition,
ShortcutHandler,
ShortcutPreferenceKey,
ShortcutPreferenceValue,
ShortcutRuntimeConfig
} from '@shared/shortcuts/types'
import { coerceShortcutPreference } from '@shared/shortcuts/utils'
import type { BrowserWindow } from 'electron'
import { globalShortcut } from 'electron'
import { configManager } from './ConfigManager'
import selectionService from './SelectionService'
import { windowService } from './WindowService'
const logger = loggerService.withContext('ShortcutService')
let showAppAccelerator: string | null = null
let showMiniWindowAccelerator: string | null = null
let selectionAssistantToggleAccelerator: string | null = null
let selectionAssistantSelectTextAccelerator: string | null = null
const toAccelerator = (keys: string[]): string => keys.join('+')
//indicate if the shortcuts are registered on app boot time
let isRegisterOnBoot = true
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope !== 'renderer')
// store the focus and blur handlers for each window to unregister them later
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
export class ShortcutService {
private handlers = new Map<ShortcutPreferenceKey, ShortcutHandler>()
private windowLifecycleHandlers = new Map<
BrowserWindow,
{ onFocus: () => void; onBlur: () => void; onClosed: () => void }
>()
private currentWindow: BrowserWindow | null = null
private preferenceUnsubscribers: Array<() => void> = []
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
case 'zoom_out':
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
case 'zoom_reset':
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
case 'show_app':
return () => {
windowService.toggleMainWindow()
}
case 'mini_window':
return () => {
windowService.toggleMiniWindow()
}
case 'selection_assistant_toggle':
return () => {
if (selectionService) {
selectionService.toggleEnabled()
}
}
case 'selection_assistant_select_text':
return () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
}
default:
return null
constructor() {
this.registerBuiltInHandlers()
this.subscribeToPreferenceChanges()
}
}
function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
const convertShortcutFormat = (shortcut: string | string[]): string => {
const accelerator = (() => {
if (Array.isArray(shortcut)) {
return shortcut
} else {
return shortcut.split('+').map((key) => key.trim())
public registerHandler(key: ShortcutPreferenceKey, handler: ShortcutHandler): void {
if (this.handlers.has(key)) {
logger.warn(`Handler for ${key} is being overwritten`)
}
})()
return accelerator
.map((key) => {
switch (key) {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// case 'Command':
// return 'CommandOrControl'
// case 'Control':
// return 'Control'
// case 'Ctrl':
// return 'Control'
// NEW WAY FOR MODIFIER KEYS
// you can see all the modifier keys in the same
case 'CommandOrControl':
return 'CommandOrControl'
case 'Ctrl':
return 'Ctrl'
case 'Alt':
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
case 'Meta':
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
case 'Shift':
return 'Shift'
// For backward compatibility with old data
case 'Command':
case 'Cmd':
return 'CommandOrControl'
case 'Control':
return 'Ctrl'
case 'ArrowUp':
return 'Up'
case 'ArrowDown':
return 'Down'
case 'ArrowLeft':
return 'Left'
case 'ArrowRight':
return 'Right'
case 'AltGraph':
return 'AltGr'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key
}
})
.join('+')
}
export function registerShortcuts(window: BrowserWindow) {
if (isRegisterOnBoot) {
window.once('ready-to-show', () => {
if (preferenceService.get('app.tray.on_launch')) {
registerOnlyUniversalShortcuts()
}
})
isRegisterOnBoot = false
this.handlers.set(key, handler)
logger.debug(`Registered handler for ${key}`)
}
//only for clearer code
const registerOnlyUniversalShortcuts = () => {
register(true)
public registerForWindow(window: BrowserWindow): void {
if (this.windowLifecycleHandlers.has(window)) {
logger.warn(`Window ${window.id} already registered for shortcuts`)
return
}
const onFocus = () => {
logger.debug(`Window ${window.id} focused - registering shortcuts`)
this.currentWindow = window
this.registerAllShortcuts(window)
}
const onBlur = () => {
logger.debug(`Window ${window.id} blurred - unregistering non-persistent shortcuts`)
this.unregisterTransientShortcuts(window)
}
const onClosed = () => {
logger.debug(`Window ${window.id} closed - cleaning up shortcut registrations`)
this.unregisterWindow(window)
}
window.on('focus', onFocus)
window.on('blur', onBlur)
window.on('closed', onClosed)
this.windowLifecycleHandlers.set(window, { onFocus, onBlur, onClosed })
this.currentWindow = window
if (window.isFocused()) {
this.registerAllShortcuts(window)
} else {
this.unregisterTransientShortcuts(window)
}
logger.info(`ShortcutService attached to window ${window.id}`)
}
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
//onlyUniversalShortcuts is needed when we launch to tray
const register = (onlyUniversalShortcuts: boolean = false) => {
if (window.isDestroyed()) return
public unregisterWindow(window: BrowserWindow): void {
const lifecycle = this.windowLifecycleHandlers.get(window)
if (!lifecycle) {
return
}
const shortcuts = configManager.getShortcuts()
if (!shortcuts) return
window.off('focus', lifecycle.onFocus)
window.off('blur', lifecycle.onBlur)
window.off('closed', lifecycle.onClosed)
shortcuts.forEach((shortcut) => {
try {
if (shortcut.shortcut.length === 0) {
this.windowLifecycleHandlers.delete(window)
if (this.currentWindow === window) {
this.currentWindow = null
globalShortcut.unregisterAll()
}
}
public cleanup(): void {
this.windowLifecycleHandlers.forEach((_handlers, window) => this.unregisterWindow(window))
this.windowLifecycleHandlers.clear()
this.handlers.clear()
this.currentWindow = null
this.preferenceUnsubscribers.forEach((unsubscribe) => unsubscribe())
this.preferenceUnsubscribers = []
globalShortcut.unregisterAll()
logger.info('ShortcutService cleaned up')
}
private registerBuiltInHandlers(): void {
this.registerHandler('shortcut.app.show_main_window', () => {
windowService.toggleMainWindow()
})
this.registerHandler('shortcut.app.show_settings', () => {
let targetWindow = windowService.getMainWindow()
if (
!targetWindow ||
targetWindow.isDestroyed() ||
targetWindow.isMinimized() ||
!targetWindow.isVisible() ||
!targetWindow.isFocused()
) {
windowService.showMainWindow()
targetWindow = windowService.getMainWindow()
}
if (!targetWindow || targetWindow.isDestroyed()) {
return
}
void targetWindow.webContents
.executeJavaScript(
`typeof window.navigate === 'function' && window.navigate('/settings/provider')`,
true
)
.catch((error) => {
logger.warn('Failed to navigate to settings from shortcut:', error as Error)
})
})
this.registerHandler('shortcut.app.show_mini_window', () => {
windowService.toggleMiniWindow()
})
this.registerHandler('shortcut.app.zoom_in', (window) => {
if (window) {
handleZoomFactor([window], 0.1)
}
})
this.registerHandler('shortcut.app.zoom_out', (window) => {
if (window) {
handleZoomFactor([window], -0.1)
}
})
this.registerHandler('shortcut.app.zoom_reset', (window) => {
if (window) {
handleZoomFactor([window], 0, true)
}
})
this.registerHandler('shortcut.selection.toggle_enabled', () => {
if (selectionService) {
selectionService.toggleEnabled()
}
})
this.registerHandler('shortcut.selection.get_text', () => {
if (selectionService) {
selectionService.processSelectTextByShortcut()
}
})
}
private subscribeToPreferenceChanges(): void {
this.preferenceUnsubscribers = relevantDefinitions.map((definition) =>
preferenceService.subscribeChange(definition.key, () => {
logger.debug(`Shortcut preference changed: ${definition.key}`)
this.reregisterShortcuts()
})
)
}
private registerAllShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
relevantDefinitions.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
logger.debug(`Skipping ${definition.key} - enabledWhen condition not met`)
return
}
const handler = this.handlers.get(definition.key)
if (!handler) {
logger.warn(`No handler registered for ${definition.key}`)
return
}
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
})
}
private unregisterTransientShortcuts(window: BrowserWindow): void {
globalShortcut.unregisterAll()
relevantDefinitions
.filter((definition) => definition.persistOnBlur)
.forEach((definition) => {
const runtimeConfig = this.getRuntimeConfig(definition)
if (!runtimeConfig.enabled) {
return
}
//if not enabled, exit early from the process.
if (!shortcut.enabled) {
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
return
}
// only register universal shortcuts when needed
if (
onlyUniversalShortcuts &&
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
shortcut.key
)
) {
return
}
const handler = getShortcutHandler(shortcut)
const handler = this.handlers.get(definition.key)
if (!handler) {
return
}
switch (shortcut.key) {
case 'show_app':
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
break
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
case 'mini_window':
//available only when QuickAssistant enabled
if (!preferenceService.get('feature.quick_assistant.enabled')) {
return
}
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_toggle':
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
break
case 'selection_assistant_select_text':
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
break
//the following ZOOMs will register shortcuts separately, so will return
case 'zoom_in':
globalShortcut.register('CommandOrControl+=', () => handler(window))
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
return
case 'zoom_out':
globalShortcut.register('CommandOrControl+-', () => handler(window))
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
return
case 'zoom_reset':
globalShortcut.register('CommandOrControl+0', () => handler(window))
return
if (definition.variants) {
definition.variants.forEach((variant) => {
this.registerSingleShortcut(variant, handler, window)
})
}
const accelerator = convertShortcutFormat(shortcut.shortcut)
globalShortcut.register(accelerator, () => handler(window))
} catch (error) {
logger.warn(`Failed to register shortcut ${shortcut.key}`)
}
})
})
}
const unregister = () => {
if (window.isDestroyed()) return
private registerSingleShortcut(keys: string[], handler: ShortcutHandler, window: BrowserWindow): void {
if (!keys.length) {
return
}
const accelerator = toAccelerator(keys)
try {
globalShortcut.unregisterAll()
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
const accelerator = convertShortcutFormat(showAppAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (showMiniWindowAccelerator) {
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantToggleAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
if (selectionAssistantSelectTextAccelerator) {
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
handler && globalShortcut.register(accelerator, () => handler(window))
}
globalShortcut.register(accelerator, () => {
logger.debug(`Shortcut triggered: ${accelerator}`)
const targetWindow = window?.isDestroyed?.() ? undefined : window
handler(targetWindow)
})
logger.verbose(`Registered shortcut: ${accelerator}`)
} catch (error) {
logger.warn('Failed to unregister shortcuts')
logger.error(`Failed to register shortcut ${accelerator}:`, error as Error)
}
}
// only register the event handlers once
if (undefined === windowOnHandlers.get(window)) {
// pass register() directly to listener, the func will receive Event as argument, it's not expected
const registerHandler = () => {
register()
private getRuntimeConfig(definition: ShortcutDefinition): ShortcutRuntimeConfig {
const preference = this.getPreference(definition)
return {
...definition,
binding: preference.binding,
enabled: preference.enabled,
editable: preference.editable,
system: preference.system
}
window.on('focus', registerHandler)
window.on('blur', unregister)
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
}
if (!window.isDestroyed() && window.isFocused()) {
register()
private getPreference(definition: ShortcutDefinition): ShortcutPreferenceValue {
const rawPreference = preferenceService.get(definition.key)
return coerceShortcutPreference(definition, rawPreference as any)
}
private getPreferenceValue = <K extends ShortcutPreferenceKey | keyof PreferenceDefaultScopeType>(
key: K
): PreferenceDefaultScopeType[K] => {
return preferenceService.get(key)
}
private reregisterShortcuts(): void {
if (!this.currentWindow || this.currentWindow.isDestroyed()) {
return
}
if (this.currentWindow.isFocused()) {
this.registerAllShortcuts(this.currentWindow)
return
}
this.unregisterTransientShortcuts(this.currentWindow)
}
}
export function unregisterAllShortcuts() {
try {
showAppAccelerator = null
showMiniWindowAccelerator = null
selectionAssistantToggleAccelerator = null
selectionAssistantSelectTextAccelerator = null
windowOnHandlers.forEach((handlers, window) => {
window.off('focus', handlers.onFocusHandler)
window.off('blur', handlers.onBlurHandler)
})
windowOnHandlers.clear()
globalShortcut.unregisterAll()
} catch (error) {
logger.warn('Failed to unregister all shortcuts')
}
}
export const shortcutService = new ShortcutService()

View File

@ -3,7 +3,7 @@ import { Box } from '@cherrystudio/ui'
import { getToastUtilities } from '@cherrystudio/ui'
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
import { useAppInit } from '@renderer/hooks/useAppInit'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
import { Modal } from 'antd'
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
@ -35,8 +35,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
elementsRef.current = elements
const [modal, modalContextHolder] = Modal.useModal()
const { shortcuts } = useShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
const shortcuts = useAllShortcuts()
const enableQuitFullScreen = shortcuts.find((item) => item.definition.key === 'shortcut.app.exit_fullscreen')
?.preference.enabled
useAppInit()

View File

@ -1,29 +1,23 @@
import { useAppSelector } from '@renderer/store'
import { IpcChannel } from '@shared/IpcChannel'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useLocation, useNavigate } from 'react-router-dom'
const NavigationHandler: React.FC = () => {
const location = useLocation()
const navigate = useNavigate()
const showSettingsShortcutEnabled = useAppSelector(
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
)
useHotkeys(
'meta+, ! ctrl+,',
function () {
useShortcut(
'shortcut.app.show_settings',
() => {
if (location.pathname.startsWith('/settings')) {
return
}
navigate('/settings/provider')
},
{
splitKey: '!',
enableOnContentEditable: true,
enableOnFormTags: true,
enabled: showSettingsShortcutEnabled
enableOnContentEditable: true
}
)

View File

@ -1,7 +1,15 @@
import { isMac, isWin } from '@renderer/config/constant'
import { useAppSelector } from '@renderer/store'
import { orderBy } from 'lodash'
import { useCallback } from 'react'
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
import { isMac } from '@renderer/config/constant'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
import type { ShortcutDefinition, ShortcutPreferenceKey, ShortcutPreferenceValue } from '@shared/shortcuts/types'
import {
coerceShortcutPreference,
convertAcceleratorToHotkey,
formatShortcutDisplay,
getDefaultShortcutPreference
} from '@shared/shortcuts/utils'
import { useCallback, useMemo } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
interface UseShortcutOptions {
@ -9,85 +17,175 @@ interface UseShortcutOptions {
enableOnFormTags?: boolean
enabled?: boolean
description?: string
enableOnContentEditable?: boolean
}
const defaultOptions: UseShortcutOptions = {
preventDefault: true,
enableOnFormTags: true,
enabled: true
enabled: true,
enableOnContentEditable: false
}
const resolvePreferenceValue = (
definition: ShortcutDefinition | undefined,
preference: PreferenceShortcutType | Record<string, unknown> | undefined
): ShortcutPreferenceValue | null => {
if (!definition) {
return null
}
return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined)
}
export const useShortcut = (
shortcutKey: string,
callback: (e: KeyboardEvent) => void,
shortcutKey: ShortcutPreferenceKey,
callback: (event: KeyboardEvent) => void,
options: UseShortcutOptions = defaultOptions
) => {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'command':
return 'meta'
case 'commandorcontrol':
return isMac ? 'meta' : 'ctrl'
default:
return key.toLowerCase()
}
})
.join('+')
}, [])
const hotkey = useMemo(() => {
if (!definition || !preferenceState) {
return 'none'
}
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
if (definition.scope === 'main') {
return 'none'
}
if (!preferenceState.enabled) {
return 'none'
}
const effectiveBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!effectiveBinding.length) {
return 'none'
}
return convertAcceleratorToHotkey(effectiveBinding)
}, [definition, preferenceState])
useHotkeys(
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
(e) => {
hotkey,
(event) => {
if (options.preventDefault) {
e.preventDefault()
event.preventDefault()
}
if (options.enabled !== false) {
callback(e)
callback(event)
}
},
{
enableOnFormTags: options.enableOnFormTags,
description: options.description || shortcutConfig?.key,
enabled: !!shortcutConfig?.enabled
}
description: options.description ?? shortcutKey,
enabled: hotkey !== 'none',
enableOnContentEditable: options.enableOnContentEditable
},
[hotkey, callback, options]
)
}
export function useShortcuts() {
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
export const useShortcutDisplay = (shortcutKey: ShortcutPreferenceKey): string => {
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
const [preference] = usePreference(shortcutKey)
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
if (!definition || !preferenceState || !preferenceState.enabled) {
return ''
}
const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
if (!displayBinding.length) {
return ''
}
return formatShortcutDisplay(displayBinding, isMac)
}
export function useShortcutDisplay(key: string) {
const formatShortcut = useCallback((shortcut: string[]) => {
return shortcut
.map((key) => {
switch (key.toLowerCase()) {
case 'control':
return isMac ? '⌃' : 'Ctrl'
case 'ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'command':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'alt':
return isMac ? '⌥' : 'Alt'
case 'shift':
return isMac ? '⇧' : 'Shift'
case 'commandorcontrol':
return isMac ? '⌘' : 'Ctrl'
default:
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
}
})
.join('+')
}, [])
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const shortcutConfig = shortcuts.find((s) => s.key === key)
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
export interface ShortcutListItem {
definition: ShortcutDefinition
preference: ShortcutPreferenceValue
defaultPreference: ShortcutPreferenceValue
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
}
export const useAllShortcuts = (): ShortcutListItem[] => {
const keyMap = useMemo(
() =>
SHORTCUT_DEFINITIONS.reduce<Record<string, ShortcutPreferenceKey>>((acc, definition) => {
acc[definition.key] = definition.key
return acc
}, {}),
[]
)
const [values, setValues] = useMultiplePreferences(keyMap)
const buildNextPreference = useCallback(
(
state: ShortcutPreferenceValue,
currentValue: PreferenceShortcutType | undefined,
patch: Partial<PreferenceShortcutType>
): PreferenceShortcutType => {
const current = (currentValue ?? {}) as PreferenceShortcutType
const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.rawBinding
const nextEnabled =
typeof patch.enabled === 'boolean'
? patch.enabled
: typeof current.enabled === 'boolean'
? current.enabled
: state.enabled
const nextEditable =
typeof patch.editable === 'boolean'
? patch.editable
: typeof current.editable === 'boolean'
? current.editable
: state.editable
const nextSystem =
typeof patch.system === 'boolean'
? patch.system
: typeof current.system === 'boolean'
? current.system
: state.system
return {
key: nextKey,
enabled: nextEnabled,
editable: nextEditable,
system: nextSystem
}
},
[]
)
return useMemo(
() =>
SHORTCUT_DEFINITIONS.map((definition) => {
const rawValue = values[definition.key] as PreferenceShortcutType | undefined
const preference = coerceShortcutPreference(definition, rawValue)
const defaultPreference = getDefaultShortcutPreference(definition)
const updatePreference = async (patch: Partial<PreferenceShortcutType>) => {
const currentValue = values[definition.key] as PreferenceShortcutType | undefined
const nextValue = buildNextPreference(preference, currentValue, patch)
await setValues({ [definition.key]: nextValue } as Partial<Record<string, PreferenceShortcutType>>)
}
return {
definition,
preference,
defaultPreference,
updatePreference
}
}),
[buildNextPreference, setValues, values]
)
}

View File

@ -70,7 +70,7 @@ const Chat: FC<Props> = (props) => {
contentSearchRef.current?.disable()
})
useShortcut('search_message_in_chat', () => {
useShortcut('shortcut.chat.search_message', () => {
try {
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
@ -79,7 +79,7 @@ const Chat: FC<Props> = (props) => {
}
})
useShortcut('rename_topic', async () => {
useShortcut('shortcut.topic.rename', async () => {
const topic = props.activeTopic
if (!topic) return
@ -98,7 +98,7 @@ const Chat: FC<Props> = (props) => {
})
useShortcut(
'new_topic',
'shortcut.topic.new',
() => {
if (activeTopicOrSession !== 'session' || !activeAgentId) {
return

View File

@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
const { showTopics, toggleShowTopics } = useShowTopics()
const { isTopNavbar } = useNavbarPosition()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_topics', () => {
useShortcut('shortcut.topic.toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@ -47,7 +47,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
}
})
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
SearchPopup.show()
})

View File

@ -48,7 +48,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
const { session } = useSession(agentId, sessionId)
const { apiServer } = useSettings()
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
const newTopicShortcut = useShortcutDisplay('new_topic')
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
const textareaRef = useRef<TextAreaRef>(null)

View File

@ -723,13 +723,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
}
}, [onPaste])
useShortcut('new_topic', () => {
useShortcut('shortcut.topic.new', () => {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
focusTextarea()
})
useShortcut('clear_topic', clearTopic)
useShortcut('shortcut.chat.clear', clearTopic)
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })

View File

@ -194,8 +194,8 @@ const InputbarTools = ({
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
}, [assistant.enableGenerateImage, updateAssistant])
const newTopicShortcut = useShortcutDisplay('new_topic')
const clearTopicShortcut = useShortcutDisplay('clear_topic')
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
const clearTopicShortcut = useShortcutDisplay('shortcut.chat.clear')
const toggleToolVisibility = useCallback(
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {

View File

@ -9,10 +9,10 @@ interface Props {
}
const NewContextButton: FC<Props> = ({ onNewContext }) => {
const newContextShortcut = useShortcutDisplay('toggle_new_context')
const newContextShortcut = useShortcutDisplay('shortcut.chat.toggle_new_context')
const { t } = useTranslation()
useShortcut('toggle_new_context', onNewContext)
useShortcut('shortcut.chat.toggle_new_context', onNewContext)
return (
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>

View File

@ -268,7 +268,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
)
}, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer])
useShortcut('copy_last_message', () => {
useShortcut('shortcut.chat.copy_last_message', () => {
const lastMessage = last(messages)
if (lastMessage) {
navigator.clipboard.writeText(getMainTextContent(lastMessage))
@ -276,7 +276,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
}
})
useShortcut('edit_last_user_message', () => {
useShortcut('shortcut.chat.edit_last_user_message', () => {
const lastUserMessage = messagesRef.current.findLast((m) => m.role === 'user' && m.type !== 'clear')
if (lastUserMessage) {
EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, lastUserMessage.id)

View File

@ -38,9 +38,9 @@ const HeaderNavbar: FC<Props> = ({
const { showAssistants, toggleShowAssistants } = useShowAssistants()
const { showTopics, toggleShowTopics } = useShowTopics()
useShortcut('toggle_show_assistants', toggleShowAssistants)
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
useShortcut('toggle_show_topics', () => {
useShortcut('shortcut.topic.toggle_show_topics', () => {
if (topicPosition === 'right') {
toggleShowTopics()
} else {
@ -48,7 +48,7 @@ const HeaderNavbar: FC<Props> = ({
}
})
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
SearchPopup.show()
})

View File

@ -93,7 +93,7 @@ const KnowledgePage: FC = () => {
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
)
useShortcut('search_message', () => {
useShortcut('shortcut.app.search_message', () => {
if (selectedBase) {
KnowledgeSearchPopup.show({ base: selectedBase }).then()
}

View File

@ -1,173 +1,150 @@
import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
import { preferenceService } from '@data/PreferenceService'
import { isMac, isWin } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { useShortcuts } from '@renderer/hooks/useShortcuts'
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
import { useTimer } from '@renderer/hooks/useTimer'
import { getShortcutLabel } from '@renderer/i18n/label'
import { useAppDispatch } from '@renderer/store'
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
import type { Shortcut } from '@renderer/types'
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
import type { ShortcutPreferenceKey } from '@shared/shortcuts/types'
import { convertKeyToAccelerator, formatShortcutDisplay, isValidShortcut } from '@shared/shortcuts/utils'
import type { InputRef } from 'antd'
import { Input, Table as AntTable } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import type { FC } from 'react'
import React, { useRef, useState } from 'react'
import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
const labelKeyMap: Record<string, string> = {
'shortcut.app.show_main_window': 'show_app',
'shortcut.app.show_mini_window': 'mini_window',
'shortcut.app.show_settings': 'show_settings',
'shortcut.app.toggle_show_assistants': 'toggle_show_assistants',
'shortcut.app.exit_fullscreen': 'exit_fullscreen',
'shortcut.app.zoom_in': 'zoom_in',
'shortcut.app.zoom_out': 'zoom_out',
'shortcut.app.zoom_reset': 'zoom_reset',
'shortcut.app.search_message': 'search_message',
'shortcut.chat.clear': 'clear_topic',
'shortcut.chat.search_message': 'search_message_in_chat',
'shortcut.chat.toggle_new_context': 'toggle_new_context',
'shortcut.chat.copy_last_message': 'copy_last_message',
'shortcut.chat.edit_last_user_message': 'edit_last_user_message',
'shortcut.topic.new': 'new_topic',
'shortcut.topic.rename': 'rename_topic',
'shortcut.topic.toggle_show_topics': 'toggle_show_topics',
'shortcut.selection.toggle_enabled': 'selection_assistant_toggle',
'shortcut.selection.get_text': 'selection_assistant_select_text'
}
type ShortcutRecord = {
id: string
label: string
key: ShortcutPreferenceKey
enabled: boolean
editable: boolean
displayKeys: string[]
rawKeys: string[]
hasCustomBinding: boolean
system: boolean
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
defaultPreference: {
binding: string[]
enabled: boolean
}
}
const ShortcutSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { shortcuts: originalShortcuts } = useShortcuts()
const shortcuts = useAllShortcuts()
const inputRefs = useRef<Record<string, InputRef>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)
const { setTimeoutTimer } = useTimer()
//if shortcut is not available on all the platforms, block the shortcut here
let shortcuts = originalShortcuts
if (!isWin && !isMac) {
//Selection Assistant only available on Windows now
const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text']
shortcuts = shortcuts.filter((s) => !excludedShortcuts.includes(s.key))
const displayedShortcuts = useMemo<ShortcutRecord[]>(() => {
const filtered = !isWin && !isMac ? shortcuts.filter((item) => item.definition.category !== 'selection') : shortcuts
return filtered.map((item) => {
const labelKey = labelKeyMap[item.definition.key] ?? item.definition.key
const label = getShortcutLabel(labelKey)
const displayKeys = item.preference.hasCustomBinding
? item.preference.rawBinding
: item.preference.binding.length > 0
? item.preference.binding
: item.definition.defaultKey
return {
id: item.definition.key,
label,
key: item.definition.key,
enabled: item.preference.enabled,
editable: item.preference.editable,
displayKeys,
rawKeys: item.preference.rawBinding,
hasCustomBinding: item.preference.hasCustomBinding,
system: item.preference.system,
updatePreference: item.updatePreference,
defaultPreference: {
binding: item.defaultPreference.binding,
enabled: item.defaultPreference.enabled
}
}
})
}, [shortcuts])
const handleClear = (record: ShortcutRecord) => {
void record.updatePreference({ key: [] })
}
const handleClear = (record: Shortcut) => {
dispatch(
updateShortcut({
...record,
shortcut: []
})
)
}
const handleAddShortcut = (record: Shortcut) => {
setEditingKey(record.key)
const handleAddShortcut = (record: ShortcutRecord) => {
setEditingKey(record.id)
setTimeoutTimer(
'handleAddShortcut',
`focus-${record.id}`,
() => {
inputRefs.current[record.key]?.focus()
inputRefs.current[record.id]?.focus()
},
0
)
}
const isShortcutModified = (record: Shortcut) => {
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+')
const isShortcutModified = (record: ShortcutRecord) => {
const bindingChanged = record.hasCustomBinding
? record.rawKeys.length !== record.defaultPreference.binding.length ||
record.rawKeys.some((key, index) => key !== record.defaultPreference.binding[index])
: false
const enabledChanged = record.enabled !== record.defaultPreference.enabled
return bindingChanged || enabledChanged
}
const handleResetShortcut = (record: Shortcut) => {
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
if (defaultShortcut) {
dispatch(
updateShortcut({
...record,
shortcut: defaultShortcut.shortcut
})
)
}
const handleResetShortcut = (record: ShortcutRecord) => {
void record.updatePreference({
key: record.defaultPreference.binding,
enabled: record.defaultPreference.enabled
})
setEditingKey(null)
}
const isValidShortcut = (keys: string[]): boolean => {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
// const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
const isDuplicateShortcut = (keys: string[], currentKey: ShortcutPreferenceKey) => {
const normalized = keys.map((key) => key.toLowerCase()).join('+')
// NEW WAY FOR MODIFIER KEYS
const hasModifier = keys.some((key) => ['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
const hasNonModifier = keys.some((key) => !['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
const hasFnKey = keys.some((key) => /^F\d+$/.test(key))
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
return displayedShortcuts.some((record) => {
if (record.key === currentKey) return false
const binding = record.displayKeys
if (!binding.length) return false
return binding.map((key) => key.toLowerCase()).join('+') === normalized
})
}
const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => {
return shortcuts.some(
(s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+')
)
}
// how the shortcut is displayed in the UI
const formatShortcut = (shortcut: string[]): string => {
return shortcut
.map((key) => {
switch (key) {
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// case 'Control':
// return isMac ? '⌃' : 'Ctrl'
// case 'Ctrl':
// return isMac ? '⌃' : 'Ctrl'
// case 'Command':
// return isMac ? '⌘' : isWin ? 'Win' : 'Super'
// case 'Alt':
// return isMac ? '⌥' : 'Alt'
// case 'Shift':
// return isMac ? '⇧' : 'Shift'
// case 'CommandOrControl':
// return isMac ? '⌘' : 'Ctrl'
// new way for modifier keys
case 'CommandOrControl':
return isMac ? '⌘' : 'Ctrl'
case 'Ctrl':
return isMac ? '⌃' : 'Ctrl'
case 'Alt':
return isMac ? '⌥' : 'Alt'
case 'Meta':
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
case 'Shift':
return isMac ? '⇧' : 'Shift'
// for backward compatibility with old data
case 'Command':
case 'Cmd':
return isMac ? '⌘' : 'Ctrl'
case 'Control':
return isMac ? '⌃' : 'Ctrl'
case 'ArrowUp':
return '↑'
case 'ArrowDown':
return '↓'
case 'ArrowLeft':
return '←'
case 'ArrowRight':
return '→'
case 'Slash':
return '/'
case 'Semicolon':
return ';'
case 'BracketLeft':
return '['
case 'BracketRight':
return ']'
case 'Backslash':
return '\\'
case 'Quote':
return "'"
case 'Comma':
return ','
case 'Minus':
return '-'
case 'Equal':
return '='
default:
return key.charAt(0).toUpperCase() + key.slice(1)
}
})
.join(' + ')
}
const usableEndKeys = (event: React.KeyboardEvent): string | null => {
const usableEndKeys = (event: ReactKeyboardEvent): string | null => {
const { code } = event
// No lock keys
// Among the commonly used keys, not including: Escape, NumpadMultiply, NumpadDivide, NumpadSubtract, NumpadAdd, NumpadDecimal
// The react-hotkeys-hook library does not differentiate between `Digit` and `Numpad`
switch (code) {
case 'KeyA':
case 'KeyB':
@ -217,10 +194,15 @@ const ShortcutSettings: FC = () => {
case 'Numpad9':
return code.slice(-1)
case 'Space':
return 'Space'
case 'Enter':
return 'Enter'
case 'Backspace':
return 'Backspace'
case 'Tab':
return 'Tab'
case 'Delete':
return 'Delete'
case 'PageUp':
case 'PageDown':
case 'Insert':
@ -256,7 +238,6 @@ const ShortcutSettings: FC = () => {
return '.'
case 'NumpadEnter':
return 'Enter'
// The react-hotkeys-hook library does not handle the symbol strings for the following keys
case 'Slash':
case 'Semicolon':
case 'BracketLeft':
@ -272,28 +253,19 @@ const ShortcutSettings: FC = () => {
}
}
const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => {
e.preventDefault()
const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => {
event.preventDefault()
const keys: string[] = []
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
// if (e.ctrlKey) keys.push(isMac ? 'Control' : 'Ctrl')
// if (e.metaKey) keys.push('Command')
// if (e.altKey) keys.push('Alt')
// if (e.shiftKey) keys.push('Shift')
if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl')
if (event.altKey) keys.push('Alt')
if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta')
if (event.shiftKey) keys.push('Shift')
// NEW WAY FOR MODIFIER KEYS
// for capability across platforms, we transform the modifier keys to the really meaning keys
// mainly consider the habit of users on different platforms
if (e.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS
if (e.altKey) keys.push('Alt')
if (e.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux
if (e.shiftKey) keys.push('Shift')
const endKey = usableEndKeys(e)
const endKey = usableEndKeys(event)
if (endKey) {
keys.push(endKey)
keys.push(convertKeyToAccelerator(endKey))
}
if (!isValidShortcut(keys)) {
@ -304,7 +276,7 @@ const ShortcutSettings: FC = () => {
return
}
dispatch(updateShortcut({ ...record, shortcut: keys }))
void record.updatePreference({ key: keys })
setEditingKey(null)
}
@ -312,50 +284,60 @@ const ShortcutSettings: FC = () => {
window.modal.confirm({
title: t('settings.shortcuts.reset_defaults_confirm'),
centered: true,
onOk: () => dispatch(resetShortcuts())
onOk: async () => {
const updates: Record<string, PreferenceShortcutType> = {}
shortcuts.forEach((item) => {
updates[item.definition.key] = {
key: item.defaultPreference.binding,
enabled: item.defaultPreference.enabled,
editable: item.defaultPreference.editable,
system: item.defaultPreference.system
}
})
await preferenceService.setMultiple(updates)
}
})
}
// 由于启用了showHeader = false不再需要title字段
const columns: ColumnsType<Shortcut> = [
const columns: ColumnsType<ShortcutRecord> = [
{
// title: t('settings.shortcuts.action'),
dataIndex: 'name',
key: 'name'
dataIndex: 'label',
key: 'label'
},
{
// title: t('settings.shortcuts.label'),
dataIndex: 'shortcut',
dataIndex: 'displayKeys',
key: 'shortcut',
align: 'right',
render: (shortcut: string[], record: Shortcut) => {
const isEditing = editingKey === record.key
const shortcutConfig = shortcuts.find((s) => s.key === record.key)
const isEditable = shortcutConfig?.editable !== false
render: (_value, record) => {
const isEditing = editingKey === record.id
const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : ''
const editingShortcut = record.rawKeys.length > 0 ? formatShortcutDisplay(record.rawKeys, isMac) : ''
return (
<RowFlex className="items-center justify-end gap-2">
<RowFlex className="relative items-center">
{isEditing ? (
<ShortcutInput
ref={(el) => {
if (el) {
inputRefs.current[record.key] = el
ref={(element) => {
if (element) {
inputRefs.current[record.id] = element
}
}}
value={formatShortcut(shortcut)}
value={editingShortcut}
placeholder={t('settings.shortcuts.press_shortcut')}
onKeyDown={(e) => handleKeyDown(e, record)}
onBlur={(e) => {
const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon')
onKeyDown={(event) => handleKeyDown(event, record)}
onBlur={(event) => {
const isUndoClick = event.relatedTarget?.closest('.shortcut-undo-icon')
if (!isUndoClick) {
setEditingKey(null)
}
}}
/>
) : (
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
{shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')}
<ShortcutText isEditable={record.editable} onClick={() => record.editable && handleAddShortcut(record)}>
{displayShortcut || t('settings.shortcuts.press_shortcut')}
</ShortcutText>
)}
</RowFlex>
@ -364,11 +346,10 @@ const ShortcutSettings: FC = () => {
}
},
{
// title: t('settings.shortcuts.actions'),
key: 'actions',
align: 'right',
width: '70px',
render: (record: Shortcut) => (
width: 70,
render: (record) => (
<RowFlex className="items-center justify-end gap-2">
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
@ -379,7 +360,7 @@ const ShortcutSettings: FC = () => {
<Button
size="icon-sm"
onClick={() => handleClear(record)}
disabled={record.shortcut.length === 0 || !record.editable}>
disabled={record.rawKeys.length === 0 || !record.editable}>
<ClearOutlined />
</Button>
</Tooltip>
@ -387,12 +368,15 @@ const ShortcutSettings: FC = () => {
)
},
{
// title: t('settings.shortcuts.enabled'),
key: 'enabled',
align: 'right',
width: '50px',
render: (record: Shortcut) => (
<Switch size="sm" isSelected={record.enabled} onValueChange={() => dispatch(toggleShortcut(record.key))} />
width: 50,
render: (record) => (
<Switch
size="sm"
isSelected={record.enabled}
onValueChange={() => void record.updatePreference({ enabled: !record.enabled })}
/>
)
}
]
@ -404,10 +388,11 @@ const ShortcutSettings: FC = () => {
<SettingDivider style={{ marginBottom: 0 }} />
<Table
columns={columns as ColumnsType<unknown>}
dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))}
dataSource={displayedShortcuts}
pagination={false}
size="middle"
showHeader={false}
rowKey="id"
/>
<SettingDivider style={{ marginBottom: 0 }} />
<RowFlex className="justify-end p-4">