From 34affb453337879a791f9520a0e8218af1bbf8cb Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Thu, 30 Oct 2025 11:28:41 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20migrate=20shor?= =?UTF-8?q?tcuts=20to=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/preference/preferenceSchemas.ts | 24 + packages/shared/shortcuts/definitions.ts | 148 +++++ packages/shared/shortcuts/types.ts | 40 ++ packages/shared/shortcuts/utils.ts | 136 +++++ src/main/index.ts | 4 +- src/main/ipc.ts | 13 - src/main/services/ShortcutService.ts | 512 +++++++++--------- src/renderer/src/components/TopView/index.tsx | 7 +- .../src/handler/NavigationHandler.tsx | 16 +- src/renderer/src/hooks/useShortcuts.ts | 216 ++++++-- src/renderer/src/pages/home/Chat.tsx | 6 +- src/renderer/src/pages/home/ChatNavbar.tsx | 6 +- .../home/Inputbar/AgentSessionInputbar.tsx | 2 +- .../src/pages/home/Inputbar/Inputbar.tsx | 4 +- .../src/pages/home/Inputbar/InputbarTools.tsx | 4 +- .../pages/home/Inputbar/NewContextButton.tsx | 4 +- .../src/pages/home/Messages/Messages.tsx | 4 +- src/renderer/src/pages/home/Navbar.tsx | 6 +- .../src/pages/knowledge/KnowledgePage.tsx | 2 +- .../src/pages/settings/ShortcutSettings.tsx | 347 ++++++------ 20 files changed, 963 insertions(+), 538 deletions(-) create mode 100644 packages/shared/shortcuts/definitions.ts create mode 100644 packages/shared/shortcuts/types.ts create mode 100644 packages/shared/shortcuts/utils.ts diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 6b758c0ff5..e84757da31 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -373,6 +373,8 @@ export interface PreferenceSchemas { 'shortcut.chat.clear': Record // redux/shortcuts/shortcuts.copy_last_message 'shortcut.chat.copy_last_message': Record + // redux/shortcuts/shortcuts.edit_last_user_message + 'shortcut.chat.edit_last_user_message': Record // redux/shortcuts/shortcuts.search_message_in_chat 'shortcut.chat.search_message': Record // redux/shortcuts/shortcuts.toggle_new_context @@ -383,6 +385,10 @@ export interface PreferenceSchemas { 'shortcut.selection.toggle_enabled': Record // redux/shortcuts/shortcuts.new_topic 'shortcut.topic.new': Record + // redux/shortcuts/shortcuts.rename_topic + 'shortcut.topic.rename': Record + // redux/shortcuts/shortcuts.toggle_show_topics + 'shortcut.topic.toggle_show_topics': Record // 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', diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts new file mode 100644 index 0000000000..56fb201e91 --- /dev/null +++ b/packages/shared/shortcuts/definitions.ts @@ -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 = { + 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) +} diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts new file mode 100644 index 0000000000..1a94d448d5 --- /dev/null +++ b/packages/shared/shortcuts/types.ts @@ -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 + +export type GetPreferenceFn = (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 diff --git a/packages/shared/shortcuts/utils.ts b/packages/shared/shortcuts/utils.ts new file mode 100644 index 0000000000..75cc47604c --- /dev/null +++ b/packages/shared/shortcuts/utils.ts @@ -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 = { + 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' diff --git a/src/main/index.ts b/src/main/index.ts index 2a4b0a022b..386ce0e553 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 709bf24834..1bc5966e11 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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)) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 4f4e93256b..328da0816c 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -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 void; onBlurHandler: () => void }>() +export class ShortcutService { + private handlers = new Map() + 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 = ( + 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() diff --git a/src/renderer/src/components/TopView/index.tsx b/src/renderer/src/components/TopView/index.tsx index c2869a3917..3c78be731f 100644 --- a/src/renderer/src/components/TopView/index.tsx +++ b/src/renderer/src/components/TopView/index.tsx @@ -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 = ({ 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() diff --git a/src/renderer/src/handler/NavigationHandler.tsx b/src/renderer/src/handler/NavigationHandler.tsx index 5e1ef56113..99e1f84ad5 100644 --- a/src/renderer/src/handler/NavigationHandler.tsx +++ b/src/renderer/src/handler/NavigationHandler.tsx @@ -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 } ) diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ef92a5f970..7387b17b20 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -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 | 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) => Promise +} + +export const useAllShortcuts = (): ShortcutListItem[] => { + const keyMap = useMemo( + () => + SHORTCUT_DEFINITIONS.reduce>((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 => { + 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) => { + const currentValue = values[definition.key] as PreferenceShortcutType | undefined + const nextValue = buildNextPreference(preference, currentValue, patch) + await setValues({ [definition.key]: nextValue } as Partial>) + } + + return { + definition, + preference, + defaultPreference, + updatePreference + } + }), + [buildNextPreference, setValues, values] + ) } diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 1b4a342057..78b4c1452a 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -70,7 +70,7 @@ const Chat: FC = (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) => { } }) - useShortcut('rename_topic', async () => { + useShortcut('shortcut.topic.rename', async () => { const topic = props.activeTopic if (!topic) return @@ -98,7 +98,7 @@ const Chat: FC = (props) => { }) useShortcut( - 'new_topic', + 'shortcut.topic.new', () => { if (activeTopicOrSession !== 'session' || !activeAgentId) { return diff --git a/src/renderer/src/pages/home/ChatNavbar.tsx b/src/renderer/src/pages/home/ChatNavbar.tsx index 47120e7020..c6203007b4 100644 --- a/src/renderer/src/pages/home/ChatNavbar.tsx +++ b/src/renderer/src/pages/home/ChatNavbar.tsx @@ -37,9 +37,9 @@ const HeaderNavbar: FC = ({ 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 = ({ activeAssistant, setActiveAssistant, activeTo } }) - useShortcut('search_message', () => { + useShortcut('shortcut.app.search_message', () => { SearchPopup.show() }) diff --git a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx index 2eff246733..c72343db58 100644 --- a/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/AgentSessionInputbar.tsx @@ -48,7 +48,7 @@ const AgentSessionInputbar: FC = ({ 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(null) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index ed59d6dcb6..92f6a0c687 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -723,13 +723,13 @@ const Inputbar: FC = ({ 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 }) diff --git a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx index b7c0cf6c3b..9ec0da4531 100644 --- a/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx +++ b/src/renderer/src/pages/home/Inputbar/InputbarTools.tsx @@ -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) => { diff --git a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx index b8ccbd836e..e4fad2c689 100644 --- a/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/NewContextButton.tsx @@ -9,10 +9,10 @@ interface Props { } const NewContextButton: FC = ({ 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 ( diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index f37e829a2a..f05fbada7a 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -268,7 +268,7 @@ const Messages: React.FC = ({ 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 = ({ 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) diff --git a/src/renderer/src/pages/home/Navbar.tsx b/src/renderer/src/pages/home/Navbar.tsx index fc3e1a76a1..85663a0070 100644 --- a/src/renderer/src/pages/home/Navbar.tsx +++ b/src/renderer/src/pages/home/Navbar.tsx @@ -38,9 +38,9 @@ const HeaderNavbar: FC = ({ 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 = ({ } }) - useShortcut('search_message', () => { + useShortcut('shortcut.app.search_message', () => { SearchPopup.show() }) diff --git a/src/renderer/src/pages/knowledge/KnowledgePage.tsx b/src/renderer/src/pages/knowledge/KnowledgePage.tsx index 5c62c22ac6..22a6822d1d 100644 --- a/src/renderer/src/pages/knowledge/KnowledgePage.tsx +++ b/src/renderer/src/pages/knowledge/KnowledgePage.tsx @@ -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() } diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index e42ee98032..658be4aeb6 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -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 = { + '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) => Promise + 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>({}) const [editingKey, setEditingKey] = useState(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(() => { + 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 = {} + + 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 = [ + const columns: ColumnsType = [ { - // 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 ( {isEditing ? ( { - 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) } }} /> ) : ( - isEditable && handleAddShortcut(record)}> - {shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')} + record.editable && handleAddShortcut(record)}> + {displayShortcut || t('settings.shortcuts.press_shortcut')} )} @@ -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) => ( @@ -387,12 +368,15 @@ const ShortcutSettings: FC = () => { ) }, { - // title: t('settings.shortcuts.enabled'), key: 'enabled', align: 'right', - width: '50px', - render: (record: Shortcut) => ( - dispatch(toggleShortcut(record.key))} /> + width: 50, + render: (record) => ( + void record.updatePreference({ enabled: !record.enabled })} + /> ) } ] @@ -404,10 +388,11 @@ const ShortcutSettings: FC = () => { } - dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))} + dataSource={displayedShortcuts} pagination={false} size="middle" showHeader={false} + rowKey="id" />