diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index a5a9f54f9b..bf2b7dbad0 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -204,7 +204,9 @@ export enum IpcChannel { Export_Word = 'export:word', + Shortcuts_GetAll = 'shortcuts:getAll', Shortcuts_Update = 'shortcuts:update', + Shortcuts_Updated = 'shortcuts:updated', // backup Backup_Backup = 'backup:backup', diff --git a/packages/shared/data/preference/preferenceSchemas.ts b/packages/shared/data/preference/preferenceSchemas.ts index 6b758c0ff5..0cc3f4caba 100644 --- a/packages/shared/data/preference/preferenceSchemas.ts +++ b/packages/shared/data/preference/preferenceSchemas.ts @@ -11,12 +11,23 @@ import { TRANSLATE_PROMPT } from '@shared/config/prompts' import * as PreferenceTypes from '@shared/data/preference/preferenceTypes' +import { shortcutDefinitions } from '@shared/shortcuts/definitions' /* eslint @typescript-eslint/member-ordering: ["error", { "interfaces": { "order": "alphabetically" }, "typeLiterals": { "order": "alphabetically" } }] */ +const defaultShortcutPreferences: PreferenceTypes.ShortcutPreferencesValue = Object.fromEntries( + shortcutDefinitions.map((definition) => [ + definition.name, + { + enabled: definition.defaultEnabled, + key: [...definition.defaultKey] + } + ]) +) + export interface PreferenceSchemas { default: { // redux/settings/enableDeveloperMode @@ -377,6 +388,8 @@ export interface PreferenceSchemas { 'shortcut.chat.search_message': Record // redux/shortcuts/shortcuts.toggle_new_context 'shortcut.chat.toggle_new_context': Record + // unified shortcut overrides + 'shortcut.preferences': PreferenceTypes.ShortcutPreferencesValue // redux/shortcuts/shortcuts.selection_assistant_select_text 'shortcut.selection.get_text': Record // redux/shortcuts/shortcuts.selection_assistant_toggle @@ -645,6 +658,7 @@ export const DefaultPreferences: PreferenceSchemas = { key: ['CommandOrControl', 'K'], system: false }, + 'shortcut.preferences': defaultShortcutPreferences, '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 }, diff --git a/packages/shared/data/preference/preferenceTypes.ts b/packages/shared/data/preference/preferenceTypes.ts index 182504e4dd..a820b530e8 100644 --- a/packages/shared/data/preference/preferenceTypes.ts +++ b/packages/shared/data/preference/preferenceTypes.ts @@ -1,3 +1,5 @@ +import type { ShortcutPreferenceMap } from '@shared/shortcuts/types' + import type { PreferenceSchemas } from './preferenceSchemas' export type PreferenceDefaultScopeType = PreferenceSchemas['default'] @@ -14,6 +16,8 @@ export type PreferenceShortcutType = { system: boolean } +export type ShortcutPreferencesValue = ShortcutPreferenceMap + export enum SelectionTriggerMode { Selected = 'selected', Ctrlkey = 'ctrlkey', diff --git a/packages/shared/shortcuts/definitions.ts b/packages/shared/shortcuts/definitions.ts new file mode 100644 index 0000000000..1ce75f49c2 --- /dev/null +++ b/packages/shared/shortcuts/definitions.ts @@ -0,0 +1,175 @@ +import type { ShortcutDefinition } from './types' + +export const shortcutDefinitions: ShortcutDefinition[] = [ + { + name: 'show_app', + defaultKey: [], + defaultEnabled: true, + description: 'Show or hide the main window', + scope: 'main', + editable: true, + system: true + }, + { + name: 'show_mini_window', + defaultKey: ['CommandOrControl', 'E'], + defaultEnabled: false, + description: 'Show or hide the mini window', + scope: 'main', + editable: true, + system: true + }, + { + name: 'selection_assistant_toggle', + defaultKey: [], + defaultEnabled: false, + description: 'Enable or disable the selection assistant', + scope: 'main', + editable: true, + system: true + }, + { + name: 'selection_assistant_select_text', + defaultKey: [], + defaultEnabled: false, + description: 'Trigger selection assistant text capture', + scope: 'main', + editable: true, + system: true + }, + { + name: 'zoom_in', + defaultKey: ['CommandOrControl', '='], + defaultEnabled: true, + description: 'Zoom in', + scope: 'main', + editable: false, + system: true + }, + { + name: 'zoom_out', + defaultKey: ['CommandOrControl', '-'], + defaultEnabled: true, + description: 'Zoom out', + scope: 'main', + editable: false, + system: true + }, + { + name: 'zoom_reset', + defaultKey: ['CommandOrControl', '0'], + defaultEnabled: true, + description: 'Reset zoom', + scope: 'main', + editable: false, + system: true + }, + { + name: 'show_settings', + defaultKey: ['CommandOrControl', ','], + defaultEnabled: true, + description: 'Open settings', + scope: 'renderer', + editable: false, + system: true + }, + { + name: 'new_topic', + defaultKey: ['CommandOrControl', 'N'], + defaultEnabled: true, + description: 'Start a new chat topic', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'rename_topic', + defaultKey: ['CommandOrControl', 'T'], + defaultEnabled: false, + description: 'Rename current topic', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'toggle_show_assistants', + defaultKey: ['CommandOrControl', '['], + defaultEnabled: true, + description: 'Toggle assistant sidebar', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'toggle_show_topics', + defaultKey: ['CommandOrControl', ']'], + defaultEnabled: true, + description: 'Toggle topic sidebar', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'copy_last_message', + defaultKey: ['CommandOrControl', 'Shift', 'C'], + defaultEnabled: false, + description: 'Copy the last assistant reply', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'edit_last_user_message', + defaultKey: ['CommandOrControl', 'Shift', 'E'], + defaultEnabled: false, + description: 'Edit the last user message', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'search_message_in_chat', + defaultKey: ['CommandOrControl', 'F'], + defaultEnabled: true, + description: 'Search messages in current chat', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'search_message', + defaultKey: ['CommandOrControl', 'Shift', 'F'], + defaultEnabled: true, + description: 'Search messages globally', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'clear_topic', + defaultKey: ['CommandOrControl', 'L'], + defaultEnabled: true, + description: 'Clear current topic', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'toggle_new_context', + defaultKey: ['CommandOrControl', 'K'], + defaultEnabled: true, + description: 'Toggle new context mode', + scope: 'renderer', + editable: true, + system: false + }, + { + name: 'exit_fullscreen', + defaultKey: ['Escape'], + defaultEnabled: true, + description: 'Exit fullscreen mode', + scope: 'renderer', + editable: false, + system: true + } +] diff --git a/packages/shared/shortcuts/types.ts b/packages/shared/shortcuts/types.ts new file mode 100644 index 0000000000..f9391205c5 --- /dev/null +++ b/packages/shared/shortcuts/types.ts @@ -0,0 +1,25 @@ +export type ShortcutScope = 'main' | 'renderer' + +export interface ShortcutDefinition { + name: string + defaultKey: string[] + defaultEnabled: boolean + description: string + scope: ShortcutScope + editable: boolean + system: boolean +} + +export interface ShortcutPreferenceEntry { + key?: string[] + enabled?: boolean +} + +export type ShortcutPreferenceMap = Record + +export type HydratedShortcut = ShortcutDefinition & { + key: string[] + enabled: boolean +} + +export type HydratedShortcutMap = Record diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 709bf24834..6244a5baad 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -14,6 +14,7 @@ import type { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant' import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes' import { IpcChannel } from '@shared/IpcChannel' +import type { ShortcutPreferenceMap } from '@shared/shortcuts/types' import type { AgentPersistedMessage, FileMetadata, @@ -35,7 +36,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 +56,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, @@ -582,13 +581,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { }) // shortcuts - ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => { - configManager.setShortcuts(shortcuts) - // Refresh shortcuts registration - if (mainWindow) { - unregisterAllShortcuts() - registerShortcuts(mainWindow) + ipcMain.handle(IpcChannel.Shortcuts_Update, async (_, shortcuts: Shortcut[]) => { + const existingPreferences = preferenceService.get('shortcut.preferences') ?? {} + const nextPreferences: ShortcutPreferenceMap = { ...existingPreferences } + + for (const shortcut of shortcuts) { + const name = shortcut.key === 'mini_window' ? 'show_mini_window' : shortcut.key + nextPreferences[name] = { + key: [...shortcut.shortcut], + enabled: shortcut.enabled + } } + + await preferenceService.set('shortcut.preferences', nextPreferences) }) ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService)) diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 4f4e93256b..fbd8477712 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,298 +1,349 @@ import { preferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { handleZoomFactor } from '@main/utils/zoom' -import type { Shortcut } from '@types' +import { IpcChannel } from '@shared/IpcChannel' +import { shortcutDefinitions } from '@shared/shortcuts/definitions' +import type { HydratedShortcut, ShortcutDefinition, ShortcutPreferenceMap } from '@shared/shortcuts/types' import type { BrowserWindow } from 'electron' -import { globalShortcut } from 'electron' +import { BrowserWindow as ElectronBrowserWindow, globalShortcut, ipcMain } 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 +type ShortcutHandler = (window: BrowserWindow | undefined) => void -//indicate if the shortcuts are registered on app boot time -let isRegisterOnBoot = true +class ShortcutService { + private handlers = new Map() + private hydratedShortcuts = new Map() + private registeredAccelerators = new Map() + private readonly definitionMap = new Map() + private ipcRegistered = false -// store the focus and blur handlers for each window to unregister them later -const windowOnHandlers = new Map void; onBlurHandler: () => void }>() + constructor() { + this.definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition])) -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() + this.setupIpcHandlers() + this.registerDefaultHandlers() + this.hydrateShortcuts() + this.registerPreferenceListeners() + } + + public registerHandler(name: string, handler: ShortcutHandler) { + if (this.handlers.has(name)) { + logger.warn(`Handler for shortcut '${name}' is being overwritten.`) + } + this.handlers.set(name, handler) + } + + public registerMainProcessShortcuts(window?: BrowserWindow) { + const targetWindow = this.getTargetWindow(window) + + this.unregisterTrackedAccelerators() + + for (const config of this.hydratedShortcuts.values()) { + if (config.scope !== 'main') { + continue } - case 'mini_window': - return () => { - windowService.toggleMiniWindow() + + if (!config.enabled || config.key.length === 0) { + continue } - case 'selection_assistant_toggle': - return () => { - if (selectionService) { - selectionService.toggleEnabled() + + const handler = this.handlers.get(config.name) + if (!handler) { + logger.warn(`No handler registered for shortcut '${config.name}'.`) + continue + } + + const accelerators = this.buildAccelerators(config) + if (accelerators.length === 0) { + continue + } + + for (const accelerator of accelerators) { + try { + const registered = globalShortcut.register(accelerator, () => { + try { + handler(this.getTargetWindow(targetWindow)) + } catch (error) { + logger.error(`Error while executing handler for shortcut '${config.name}':`, error as Error) + } + }) + + if (!registered) { + logger.warn(`Electron rejected shortcut accelerator '${accelerator}' for '${config.name}'.`) + continue + } + + this.trackAccelerator(config.name, accelerator) + } catch (error) { + logger.warn(`Failed to register shortcut '${config.name}' with accelerator '${accelerator}':`, error as Error) } } - case 'selection_assistant_select_text': - return () => { - if (selectionService) { - selectionService.processSelectTextByShortcut() + } + + this.broadcastShortcuts() + } + + public unregisterAllShortcuts() { + this.unregisterTrackedAccelerators() + } + + public getHydratedShortcuts(): Record { + return Object.fromEntries( + [...this.hydratedShortcuts.entries()].map(([name, config]) => [ + name, + { + ...config, + key: [...config.key] + } + ]) + ) + } + + private setupIpcHandlers() { + if (this.ipcRegistered) { + return + } + + ipcMain.handle(IpcChannel.Shortcuts_GetAll, () => { + return this.getHydratedShortcuts() + }) + + this.ipcRegistered = true + } + + private registerPreferenceListeners() { + preferenceService.subscribeChange('shortcut.preferences', (newPreferences) => { + this.hydrateAndRegister(newPreferences) + }) + } + + private hydrateAndRegister(preferences?: ShortcutPreferenceMap) { + this.hydrateShortcuts(preferences) + this.registerMainProcessShortcuts() + } + + private hydrateShortcuts(preferences?: ShortcutPreferenceMap) { + const preferenceSnapshot = preferences ?? preferenceService.get('shortcut.preferences') + + this.hydratedShortcuts.clear() + + for (const definition of shortcutDefinitions) { + const userPreference = preferenceSnapshot?.[definition.name] + const key = + userPreference?.key && userPreference.key.length > 0 ? [...userPreference.key] : [...definition.defaultKey] + const enabled = typeof userPreference?.enabled === 'boolean' ? userPreference.enabled : definition.defaultEnabled + + this.hydratedShortcuts.set(definition.name, { + ...definition, + key, + enabled + }) + } + } + + private broadcastShortcuts() { + const payload = this.getHydratedShortcuts() + + for (const window of ElectronBrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + + try { + window.webContents.send(IpcChannel.Shortcuts_Updated, payload) + } catch (error) { + logger.warn('Failed to broadcast shortcut update to renderer window:', error as Error) + } + } + } + + private unregisterTrackedAccelerators() { + for (const accelerators of this.registeredAccelerators.values()) { + for (const accelerator of accelerators) { + try { + globalShortcut.unregister(accelerator) + } catch (error) { + logger.warn(`Failed to unregister accelerator '${accelerator}':`, error as Error) } } - default: + } + + this.registeredAccelerators.clear() + } + + private trackAccelerator(name: string, accelerator: string) { + if (!this.registeredAccelerators.has(name)) { + this.registeredAccelerators.set(name, []) + } + this.registeredAccelerators.get(name)!.push(accelerator) + } + + private buildAccelerators(config: HydratedShortcut): string[] { + if (config.key.length === 0) { + return [] + } + + const baseAccelerator = this.normalizeAccelerator(config.key) + if (!baseAccelerator) { + logger.warn(`Invalid shortcut configuration for '${config.name}', skipping registration.`) + return [] + } + + if (config.name === 'zoom_in' && this.isUsingDefaultKey(config)) { + return [baseAccelerator, 'CommandOrControl+numadd'] + } + + if (config.name === 'zoom_out' && this.isUsingDefaultKey(config)) { + return [baseAccelerator, 'CommandOrControl+numsub'] + } + + if (config.name === 'zoom_reset' && this.isUsingDefaultKey(config)) { + return [baseAccelerator, 'CommandOrControl+num0'] + } + + return [baseAccelerator] + } + + private isUsingDefaultKey(config: HydratedShortcut): boolean { + const definition = this.definitionMap.get(config.name) + if (!definition) { + return false + } + + if (definition.defaultKey.length !== config.key.length) { + return false + } + + return definition.defaultKey.every((key, index) => key === config.key[index]) + } + + private normalizeAccelerator(keys: string[]): string | null { + const normalizedKeys = keys.map((key) => this.normalizeKeyForElectron(key)).filter((key): key is string => !!key) + + if (normalizedKeys.length !== keys.length) { return null + } + + return normalizedKeys.join('+') + } + + private normalizeKeyForElectron(key: string): string | null { + switch (key) { + case 'CommandOrControl': + case 'Ctrl': + case 'Alt': + case 'Meta': + case 'Shift': + return key + 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 '=' + case 'Space': + return 'Space' + default: + return key + } + } + + private registerDefaultHandlers() { + this.registerHandler('zoom_in', (window) => { + const target = this.getTargetWindow(window) + if (!target) { + return + } + handleZoomFactor([target], 0.1) + }) + + this.registerHandler('zoom_out', (window) => { + const target = this.getTargetWindow(window) + if (!target) { + return + } + handleZoomFactor([target], -0.1) + }) + + this.registerHandler('zoom_reset', (window) => { + const target = this.getTargetWindow(window) + if (!target) { + return + } + handleZoomFactor([target], 0, true) + }) + + this.registerHandler('show_app', () => { + windowService.toggleMainWindow() + }) + + this.registerHandler('show_mini_window', () => { + if (!preferenceService.get('feature.quick_assistant.enabled')) { + return + } + windowService.toggleMiniWindow() + }) + + this.registerHandler('selection_assistant_toggle', () => { + selectionService?.toggleEnabled() + }) + + this.registerHandler('selection_assistant_select_text', () => { + selectionService?.processSelectTextByShortcut() + }) + } + + private getTargetWindow(window?: BrowserWindow): BrowserWindow | undefined { + if (window && !window.isDestroyed()) { + return window + } + + const mainWindow = windowService.getMainWindow() + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow + } + + return undefined } } -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()) - } - })() - - 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 const shortcutService = new ShortcutService() export function registerShortcuts(window: BrowserWindow) { - if (isRegisterOnBoot) { - window.once('ready-to-show', () => { - if (preferenceService.get('app.tray.on_launch')) { - registerOnlyUniversalShortcuts() - } - }) - isRegisterOnBoot = false - } - - //only for clearer code - const registerOnlyUniversalShortcuts = () => { - register(true) - } - - //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 - - const shortcuts = configManager.getShortcuts() - if (!shortcuts) return - - shortcuts.forEach((shortcut) => { - try { - if (shortcut.shortcut.length === 0) { - return - } - - //if not enabled, exit early from the process. - if (!shortcut.enabled) { - 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) - if (!handler) { - return - } - - switch (shortcut.key) { - case 'show_app': - showAppAccelerator = formatShortcutKey(shortcut.shortcut) - break - - 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 - } - - 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 - - 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)) - } - } catch (error) { - logger.warn('Failed to unregister shortcuts') - } - } - - // 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() - } - window.on('focus', registerHandler) - window.on('blur', unregister) - windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister }) - } - - if (!window.isDestroyed() && window.isFocused()) { - register() - } + shortcutService.registerMainProcessShortcuts(window) } 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') - } + shortcutService.unregisterAllShortcuts() } diff --git a/src/preload/index.ts b/src/preload/index.ts index 3c19f8c96a..ef5d080c5b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -237,6 +237,7 @@ const api = { }, openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path), shortcuts: { + getAll: () => ipcRenderer.invoke(IpcChannel.Shortcuts_GetAll), update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts) }, knowledgeBase: { diff --git a/src/renderer/src/handler/NavigationHandler.tsx b/src/renderer/src/handler/NavigationHandler.tsx index 5e1ef56113..a641d063d4 100644 --- a/src/renderer/src/handler/NavigationHandler.tsx +++ b/src/renderer/src/handler/NavigationHandler.tsx @@ -1,4 +1,4 @@ -import { useAppSelector } from '@renderer/store' +import { useShortcutConfig } from '@renderer/hooks/useShortcuts' import { IpcChannel } from '@shared/IpcChannel' import { useEffect } from 'react' import { useHotkeys } from 'react-hotkeys-hook' @@ -7,9 +7,8 @@ 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 - ) + const showSettingsShortcut = useShortcutConfig('show_settings') + const showSettingsShortcutEnabled = showSettingsShortcut?.enabled ?? false useHotkeys( 'meta+, ! ctrl+,', diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts index ef92a5f970..9e865bfdf7 100644 --- a/src/renderer/src/hooks/useShortcuts.ts +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -1,10 +1,13 @@ +import { preferenceService } from '@data/PreferenceService' import { isMac, isWin } from '@renderer/config/constant' -import { useAppSelector } from '@renderer/store' +import { shortcutRendererStore } from '@renderer/services/ShortcutService' +import { shortcutDefinitions } from '@shared/shortcuts/definitions' +import type { HydratedShortcut, ShortcutPreferenceEntry, ShortcutPreferenceMap } from '@shared/shortcuts/types' import { orderBy } from 'lodash' -import { useCallback } from 'react' +import { useMemo, useSyncExternalStore } from 'react' import { useHotkeys } from 'react-hotkeys-hook' -interface UseShortcutOptions { +export interface UseShortcutOptions { preventDefault?: boolean enableOnFormTags?: boolean enabled?: boolean @@ -17,77 +20,232 @@ const defaultOptions: UseShortcutOptions = { enabled: true } +const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition])) + +const toHotkeysFormat = (keys: string[]): string => { + return keys + .map((key) => { + switch (key.toLowerCase()) { + case 'commandorcontrol': + case 'command': + case 'cmd': + return 'mod' + case 'control': + case 'ctrl': + return 'ctrl' + case 'alt': + case 'altgraph': + return 'alt' + case 'shift': + return 'shift' + case 'meta': + return 'meta' + case 'arrowup': + return 'up' + case 'arrowdown': + return 'down' + case 'arrowleft': + return 'left' + case 'arrowright': + return 'right' + case 'escape': + return 'escape' + case 'space': + return 'space' + default: + return key.toLowerCase() + } + }) + .join('+') +} + +const toDisplayFormat = (keys: string[]): string => { + return keys + .map((key) => { + switch (key.toLowerCase()) { + case 'control': + case 'ctrl': + return isMac ? '⌃' : 'Ctrl' + case 'command': + case 'cmd': + case 'commandorcontrol': + return isMac ? '⌘' : 'Ctrl' + case 'meta': + return isMac ? '⌘' : isWin ? 'Win' : 'Super' + case 'alt': + case 'altgraph': + return isMac ? '⌥' : 'Alt' + case 'shift': + return isMac ? '⇧' : 'Shift' + 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 '=' + case 'escape': + return isMac ? '⎋' : 'Esc' + default: + return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase() + } + }) + .join(isMac ? '' : ' + ') +} + +const useShortcutMap = () => + useSyncExternalStore( + shortcutRendererStore.subscribe, + shortcutRendererStore.getSnapshot, + shortcutRendererStore.getServerSnapshot + ) + export const useShortcut = ( - shortcutKey: string, - callback: (e: KeyboardEvent) => void, + name: string, + callback: (event: KeyboardEvent) => void, options: UseShortcutOptions = defaultOptions ) => { - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) + const shortcuts = useShortcutMap() + const shortcutConfig = shortcuts[name] - 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 shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + const hotkey = useMemo(() => { + if ( + !shortcutConfig || + shortcutConfig.scope !== 'renderer' || + !shortcutConfig.enabled || + shortcutConfig.key.length === 0 + ) { + return null + } + return toHotkeysFormat(shortcutConfig.key) + }, [shortcutConfig]) useHotkeys( - shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none', - (e) => { + hotkey ?? 'none', + (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 ?? shortcutConfig?.description, + enabled: Boolean(hotkey && shortcutConfig?.enabled) + }, + [ + callback, + hotkey, + shortcutConfig, + options.preventDefault, + options.enableOnFormTags, + options.enabled, + options.description + ] ) } export function useShortcuts() { - const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) - return { shortcuts: orderBy(shortcuts, 'system', 'desc') } + const shortcuts = useShortcutMap() + const list = useMemo(() => { + return orderBy( + Object.values(shortcuts).map((shortcut) => ({ + ...shortcut, + key: [...shortcut.key] + })), + ['system', 'name'], + ['desc', 'asc'] + ) + }, [shortcuts]) + + return { shortcuts: list } } -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 function useShortcutConfig(name: string): HydratedShortcut | undefined { + const shortcuts = useShortcutMap() + return shortcuts[name] +} + +export function useShortcutDisplay(name: string) { + const shortcut = useShortcutConfig(name) + return useMemo(() => { + if (!shortcut || !shortcut.enabled || shortcut.key.length === 0) { + return '' + } + return toDisplayFormat(shortcut.key) + }, [shortcut]) +} + +async function writeShortcutPreferences(updater: (current: ShortcutPreferenceMap) => ShortcutPreferenceMap) { + const current = await preferenceService.get('shortcut.preferences') + const next = updater({ ...current }) + await preferenceService.set('shortcut.preferences', next) +} + +export async function setShortcutBinding(name: string, keys: string[]) { + await writeShortcutPreferences((current) => { + const entry: ShortcutPreferenceEntry = { ...current[name] } + entry.key = [...keys] + current[name] = entry + return current + }) +} + +export async function setShortcutEnabled(name: string, enabled: boolean) { + await writeShortcutPreferences((current) => { + const entry: ShortcutPreferenceEntry = { ...current[name] } + entry.enabled = enabled + current[name] = entry + return current + }) +} + +export async function resetShortcut(name: string) { + const definition = definitionMap.get(name) + if (!definition) { + return + } + + await writeShortcutPreferences((current) => { + current[name] = { + key: [...definition.defaultKey], + enabled: definition.defaultEnabled + } + return current + }) +} + +export async function resetAllShortcuts() { + await writeShortcutPreferences(() => { + return Object.fromEntries( + shortcutDefinitions.map((definition) => [ + definition.name, + { + key: [...definition.defaultKey], + enabled: definition.defaultEnabled + } + ]) + ) + }) } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 3736437fc8..64e44b3027 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -200,6 +200,7 @@ const shortcutKeyMap = { exit_fullscreen: 'settings.shortcuts.exit_fullscreen', label: 'settings.shortcuts.label', mini_window: 'settings.shortcuts.mini_window', + show_mini_window: 'settings.shortcuts.show_mini_window', new_topic: 'settings.shortcuts.new_topic', press_shortcut: 'settings.shortcuts.press_shortcut', reset_defaults: 'settings.shortcuts.reset_defaults', diff --git a/src/renderer/src/init.ts b/src/renderer/src/init.ts index 1a7693db63..851e0fc6e7 100644 --- a/src/renderer/src/init.ts +++ b/src/renderer/src/init.ts @@ -3,6 +3,7 @@ import { loggerService } from '@logger' import { startAutoSync } from './services/BackupService' import { startNutstoreAutoSync } from './services/NutstoreService' +import { initializeShortcutService } from './services/ShortcutService' import storeSyncService from './services/StoreSyncService' import { webTraceService } from './services/WebTraceService' loggerService.initWindowSource('mainWindow') @@ -36,3 +37,4 @@ function initWebTrace() { initAutoSync() initStoreSync() initWebTrace() +initializeShortcutService() diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index e42ee98032..e7f779638c 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -2,12 +2,17 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons' import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui' import { isMac, isWin } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { useShortcuts } from '@renderer/hooks/useShortcuts' +import { + resetAllShortcuts, + resetShortcut, + setShortcutBinding, + setShortcutEnabled, + useShortcuts +} 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 { shortcutDefinitions } from '@shared/shortcuts/definitions' +import type { HydratedShortcut } from '@shared/shortcuts/types' import type { InputRef } from 'antd' import { Input, Table as AntTable } from 'antd' import type { ColumnsType } from 'antd/es/table' @@ -18,10 +23,11 @@ import styled from 'styled-components' import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' +const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition])) + const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() - const dispatch = useAppDispatch() const { shortcuts: originalShortcuts } = useShortcuts() const inputRefs = useRef>({}) const [editingKey, setEditingKey] = useState(null) @@ -32,44 +38,34 @@ const ShortcutSettings: FC = () => { 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)) + shortcuts = shortcuts.filter((shortcut) => !excludedShortcuts.includes(shortcut.name)) } - const handleClear = (record: Shortcut) => { - dispatch( - updateShortcut({ - ...record, - shortcut: [] - }) - ) + const handleClear = (record: HydratedShortcut) => { + void setShortcutBinding(record.name, []) } - const handleAddShortcut = (record: Shortcut) => { - setEditingKey(record.key) + const handleAddShortcut = (record: HydratedShortcut) => { + setEditingKey(record.name) setTimeoutTimer( 'handleAddShortcut', () => { - inputRefs.current[record.key]?.focus() + inputRefs.current[record.name]?.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: HydratedShortcut) => { + const definition = definitionMap.get(record.name) + if (!definition) { + return false + } + return definition.defaultKey.join('+') !== record.key.join('+') } - 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: HydratedShortcut) => { + void resetShortcut(record.name) } const isValidShortcut = (keys: string[]): boolean => { @@ -86,9 +82,10 @@ const ShortcutSettings: FC = () => { return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey } - const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => { + const isDuplicateShortcut = (newShortcut: string[], currentName: string): boolean => { return shortcuts.some( - (s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+') + (shortcut) => + shortcut.name !== currentName && shortcut.key.length > 0 && shortcut.key.join('+') === newShortcut.join('+') ) } @@ -272,8 +269,8 @@ const ShortcutSettings: FC = () => { } } - const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => { - e.preventDefault() + const handleKeyDown = (event: React.KeyboardEvent, record: HydratedShortcut) => { + event.preventDefault() const keys: string[] = [] @@ -286,12 +283,12 @@ const ShortcutSettings: FC = () => { // 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') + if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS + if (event.altKey) keys.push('Alt') + if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux + if (event.shiftKey) keys.push('Shift') - const endKey = usableEndKeys(e) + const endKey = usableEndKeys(event) if (endKey) { keys.push(endKey) } @@ -300,11 +297,11 @@ const ShortcutSettings: FC = () => { return } - if (isDuplicateShortcut(keys, record.key)) { + if (isDuplicateShortcut(keys, record.name)) { return } - dispatch(updateShortcut({ ...record, shortcut: keys })) + void setShortcutBinding(record.name, keys) setEditingKey(null) } @@ -312,26 +309,28 @@ const ShortcutSettings: FC = () => { window.modal.confirm({ title: t('settings.shortcuts.reset_defaults_confirm'), centered: true, - onOk: () => dispatch(resetShortcuts()) + onOk: () => resetAllShortcuts() }) } // 由于启用了showHeader = false,不再需要title字段 - const columns: ColumnsType = [ + type ShortcutRow = HydratedShortcut & { displayName: string; shortcut: string[] } + + const columns: ColumnsType = [ { // title: t('settings.shortcuts.action'), - dataIndex: 'name', - key: 'name' + dataIndex: 'displayName', + key: 'displayName' }, { // title: t('settings.shortcuts.label'), dataIndex: 'shortcut', 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: (_: string[], record: ShortcutRow) => { + const isEditing = editingKey === record.name + const isEditable = record.editable !== false + const shortcutKeys = record.key return ( @@ -340,10 +339,10 @@ const ShortcutSettings: FC = () => { { if (el) { - inputRefs.current[record.key] = el + inputRefs.current[record.name] = el } }} - value={formatShortcut(shortcut)} + value={formatShortcut(shortcutKeys)} placeholder={t('settings.shortcuts.press_shortcut')} onKeyDown={(e) => handleKeyDown(e, record)} onBlur={(e) => { @@ -355,7 +354,7 @@ const ShortcutSettings: FC = () => { /> ) : ( isEditable && handleAddShortcut(record)}> - {shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')} + {shortcutKeys.length > 0 ? formatShortcut(shortcutKeys) : t('settings.shortcuts.press_shortcut')} )} @@ -368,7 +367,7 @@ const ShortcutSettings: FC = () => { key: 'actions', align: 'right', width: '70px', - render: (record: Shortcut) => ( + render: (record: ShortcutRow) => (