mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 22:52:08 +08:00
refactor by codex
This commit is contained in:
parent
3e9d9f16d6
commit
441fb1de53
@ -204,7 +204,9 @@ export enum IpcChannel {
|
|||||||
|
|
||||||
Export_Word = 'export:word',
|
Export_Word = 'export:word',
|
||||||
|
|
||||||
|
Shortcuts_GetAll = 'shortcuts:getAll',
|
||||||
Shortcuts_Update = 'shortcuts:update',
|
Shortcuts_Update = 'shortcuts:update',
|
||||||
|
Shortcuts_Updated = 'shortcuts:updated',
|
||||||
|
|
||||||
// backup
|
// backup
|
||||||
Backup_Backup = 'backup:backup',
|
Backup_Backup = 'backup:backup',
|
||||||
|
|||||||
@ -11,12 +11,23 @@
|
|||||||
|
|
||||||
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
|
import { TRANSLATE_PROMPT } from '@shared/config/prompts'
|
||||||
import * as PreferenceTypes from '@shared/data/preference/preferenceTypes'
|
import * as PreferenceTypes from '@shared/data/preference/preferenceTypes'
|
||||||
|
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||||
|
|
||||||
/* eslint @typescript-eslint/member-ordering: ["error", {
|
/* eslint @typescript-eslint/member-ordering: ["error", {
|
||||||
"interfaces": { "order": "alphabetically" },
|
"interfaces": { "order": "alphabetically" },
|
||||||
"typeLiterals": { "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 {
|
export interface PreferenceSchemas {
|
||||||
default: {
|
default: {
|
||||||
// redux/settings/enableDeveloperMode
|
// redux/settings/enableDeveloperMode
|
||||||
@ -377,6 +388,8 @@ export interface PreferenceSchemas {
|
|||||||
'shortcut.chat.search_message': Record<string, unknown>
|
'shortcut.chat.search_message': Record<string, unknown>
|
||||||
// redux/shortcuts/shortcuts.toggle_new_context
|
// redux/shortcuts/shortcuts.toggle_new_context
|
||||||
'shortcut.chat.toggle_new_context': Record<string, unknown>
|
'shortcut.chat.toggle_new_context': Record<string, unknown>
|
||||||
|
// unified shortcut overrides
|
||||||
|
'shortcut.preferences': PreferenceTypes.ShortcutPreferencesValue
|
||||||
// redux/shortcuts/shortcuts.selection_assistant_select_text
|
// redux/shortcuts/shortcuts.selection_assistant_select_text
|
||||||
'shortcut.selection.get_text': Record<string, unknown>
|
'shortcut.selection.get_text': Record<string, unknown>
|
||||||
// redux/shortcuts/shortcuts.selection_assistant_toggle
|
// redux/shortcuts/shortcuts.selection_assistant_toggle
|
||||||
@ -645,6 +658,7 @@ export const DefaultPreferences: PreferenceSchemas = {
|
|||||||
key: ['CommandOrControl', 'K'],
|
key: ['CommandOrControl', 'K'],
|
||||||
system: false
|
system: false
|
||||||
},
|
},
|
||||||
|
'shortcut.preferences': defaultShortcutPreferences,
|
||||||
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
|
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
|
||||||
'shortcut.selection.toggle_enabled': { 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.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||||
|
|
||||||
import type { PreferenceSchemas } from './preferenceSchemas'
|
import type { PreferenceSchemas } from './preferenceSchemas'
|
||||||
|
|
||||||
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
|
export type PreferenceDefaultScopeType = PreferenceSchemas['default']
|
||||||
@ -14,6 +16,8 @@ export type PreferenceShortcutType = {
|
|||||||
system: boolean
|
system: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ShortcutPreferencesValue = ShortcutPreferenceMap
|
||||||
|
|
||||||
export enum SelectionTriggerMode {
|
export enum SelectionTriggerMode {
|
||||||
Selected = 'selected',
|
Selected = 'selected',
|
||||||
Ctrlkey = 'ctrlkey',
|
Ctrlkey = 'ctrlkey',
|
||||||
|
|||||||
175
packages/shared/shortcuts/definitions.ts
Normal file
175
packages/shared/shortcuts/definitions.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
25
packages/shared/shortcuts/types.ts
Normal file
25
packages/shared/shortcuts/types.ts
Normal file
@ -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<string, ShortcutPreferenceEntry>
|
||||||
|
|
||||||
|
export type HydratedShortcut = ShortcutDefinition & {
|
||||||
|
key: string[]
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HydratedShortcutMap = Record<string, HydratedShortcut>
|
||||||
@ -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 { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH } from '@shared/config/constant'
|
||||||
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
|
import type { UpgradeChannel } from '@shared/data/preference/preferenceTypes'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
|
import type { ShortcutPreferenceMap } from '@shared/shortcuts/types'
|
||||||
import type {
|
import type {
|
||||||
AgentPersistedMessage,
|
AgentPersistedMessage,
|
||||||
FileMetadata,
|
FileMetadata,
|
||||||
@ -35,7 +36,6 @@ import appService from './services/AppService'
|
|||||||
import AppUpdater from './services/AppUpdater'
|
import AppUpdater from './services/AppUpdater'
|
||||||
import BackupManager from './services/BackupManager'
|
import BackupManager from './services/BackupManager'
|
||||||
import { codeToolsService } from './services/CodeToolsService'
|
import { codeToolsService } from './services/CodeToolsService'
|
||||||
import { configManager } from './services/ConfigManager'
|
|
||||||
import CopilotService from './services/CopilotService'
|
import CopilotService from './services/CopilotService'
|
||||||
import DxtService from './services/DxtService'
|
import DxtService from './services/DxtService'
|
||||||
import { ExportService } from './services/ExportService'
|
import { ExportService } from './services/ExportService'
|
||||||
@ -56,7 +56,6 @@ import { pythonService } from './services/PythonService'
|
|||||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||||
import { searchService } from './services/SearchService'
|
import { searchService } from './services/SearchService'
|
||||||
import { SelectionService } from './services/SelectionService'
|
import { SelectionService } from './services/SelectionService'
|
||||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
|
||||||
import {
|
import {
|
||||||
addEndMessage,
|
addEndMessage,
|
||||||
addStreamMessage,
|
addStreamMessage,
|
||||||
@ -582,13 +581,19 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// shortcuts
|
// shortcuts
|
||||||
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
|
ipcMain.handle(IpcChannel.Shortcuts_Update, async (_, shortcuts: Shortcut[]) => {
|
||||||
configManager.setShortcuts(shortcuts)
|
const existingPreferences = preferenceService.get('shortcut.preferences') ?? {}
|
||||||
// Refresh shortcuts registration
|
const nextPreferences: ShortcutPreferenceMap = { ...existingPreferences }
|
||||||
if (mainWindow) {
|
|
||||||
unregisterAllShortcuts()
|
for (const shortcut of shortcuts) {
|
||||||
registerShortcuts(mainWindow)
|
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))
|
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
|
||||||
|
|||||||
@ -1,298 +1,349 @@
|
|||||||
import { preferenceService } from '@data/PreferenceService'
|
import { preferenceService } from '@data/PreferenceService'
|
||||||
import { loggerService } from '@logger'
|
import { loggerService } from '@logger'
|
||||||
import { handleZoomFactor } from '@main/utils/zoom'
|
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 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 selectionService from './SelectionService'
|
||||||
import { windowService } from './WindowService'
|
import { windowService } from './WindowService'
|
||||||
|
|
||||||
const logger = loggerService.withContext('ShortcutService')
|
const logger = loggerService.withContext('ShortcutService')
|
||||||
|
|
||||||
let showAppAccelerator: string | null = null
|
type ShortcutHandler = (window: BrowserWindow | undefined) => void
|
||||||
let showMiniWindowAccelerator: string | null = null
|
|
||||||
let selectionAssistantToggleAccelerator: string | null = null
|
|
||||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
|
||||||
|
|
||||||
//indicate if the shortcuts are registered on app boot time
|
class ShortcutService {
|
||||||
let isRegisterOnBoot = true
|
private handlers = new Map<string, ShortcutHandler>()
|
||||||
|
private hydratedShortcuts = new Map<string, HydratedShortcut>()
|
||||||
|
private registeredAccelerators = new Map<string, string[]>()
|
||||||
|
private readonly definitionMap = new Map<string, ShortcutDefinition>()
|
||||||
|
private ipcRegistered = false
|
||||||
|
|
||||||
// store the focus and blur handlers for each window to unregister them later
|
constructor() {
|
||||||
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
|
this.definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
|
||||||
|
|
||||||
function getShortcutHandler(shortcut: Shortcut) {
|
this.setupIpcHandlers()
|
||||||
switch (shortcut.key) {
|
this.registerDefaultHandlers()
|
||||||
case 'zoom_in':
|
this.hydrateShortcuts()
|
||||||
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
|
this.registerPreferenceListeners()
|
||||||
case 'zoom_out':
|
}
|
||||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
|
||||||
case 'zoom_reset':
|
public registerHandler(name: string, handler: ShortcutHandler) {
|
||||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
if (this.handlers.has(name)) {
|
||||||
case 'show_app':
|
logger.warn(`Handler for shortcut '${name}' is being overwritten.`)
|
||||||
return () => {
|
}
|
||||||
windowService.toggleMainWindow()
|
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 () => {
|
if (!config.enabled || config.key.length === 0) {
|
||||||
windowService.toggleMiniWindow()
|
continue
|
||||||
}
|
}
|
||||||
case 'selection_assistant_toggle':
|
|
||||||
return () => {
|
const handler = this.handlers.get(config.name)
|
||||||
if (selectionService) {
|
if (!handler) {
|
||||||
selectionService.toggleEnabled()
|
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) {
|
this.broadcastShortcuts()
|
||||||
selectionService.processSelectTextByShortcut()
|
}
|
||||||
|
|
||||||
|
public unregisterAllShortcuts() {
|
||||||
|
this.unregisterTrackedAccelerators()
|
||||||
|
}
|
||||||
|
|
||||||
|
public getHydratedShortcuts(): Record<string, HydratedShortcut> {
|
||||||
|
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 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 {
|
export const shortcutService = new ShortcutService()
|
||||||
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 function registerShortcuts(window: BrowserWindow) {
|
export function registerShortcuts(window: BrowserWindow) {
|
||||||
if (isRegisterOnBoot) {
|
shortcutService.registerMainProcessShortcuts(window)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unregisterAllShortcuts() {
|
export function unregisterAllShortcuts() {
|
||||||
try {
|
shortcutService.unregisterAllShortcuts()
|
||||||
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')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -237,6 +237,7 @@ const api = {
|
|||||||
},
|
},
|
||||||
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
|
openPath: (path: string) => ipcRenderer.invoke(IpcChannel.Open_Path, path),
|
||||||
shortcuts: {
|
shortcuts: {
|
||||||
|
getAll: () => ipcRenderer.invoke(IpcChannel.Shortcuts_GetAll),
|
||||||
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
|
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke(IpcChannel.Shortcuts_Update, shortcuts)
|
||||||
},
|
},
|
||||||
knowledgeBase: {
|
knowledgeBase: {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useAppSelector } from '@renderer/store'
|
import { useShortcutConfig } from '@renderer/hooks/useShortcuts'
|
||||||
import { IpcChannel } from '@shared/IpcChannel'
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
@ -7,9 +7,8 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||||||
const NavigationHandler: React.FC = () => {
|
const NavigationHandler: React.FC = () => {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const showSettingsShortcutEnabled = useAppSelector(
|
const showSettingsShortcut = useShortcutConfig('show_settings')
|
||||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
const showSettingsShortcutEnabled = showSettingsShortcut?.enabled ?? false
|
||||||
)
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
'meta+, ! ctrl+,',
|
'meta+, ! ctrl+,',
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
|
import { preferenceService } from '@data/PreferenceService'
|
||||||
import { isMac, isWin } from '@renderer/config/constant'
|
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 { orderBy } from 'lodash'
|
||||||
import { useCallback } from 'react'
|
import { useMemo, useSyncExternalStore } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
|
||||||
interface UseShortcutOptions {
|
export interface UseShortcutOptions {
|
||||||
preventDefault?: boolean
|
preventDefault?: boolean
|
||||||
enableOnFormTags?: boolean
|
enableOnFormTags?: boolean
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
@ -17,77 +20,232 @@ const defaultOptions: UseShortcutOptions = {
|
|||||||
enabled: true
|
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 = (
|
export const useShortcut = (
|
||||||
shortcutKey: string,
|
name: string,
|
||||||
callback: (e: KeyboardEvent) => void,
|
callback: (event: KeyboardEvent) => void,
|
||||||
options: UseShortcutOptions = defaultOptions
|
options: UseShortcutOptions = defaultOptions
|
||||||
) => {
|
) => {
|
||||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
const shortcuts = useShortcutMap()
|
||||||
|
const shortcutConfig = shortcuts[name]
|
||||||
|
|
||||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
const hotkey = useMemo(() => {
|
||||||
return shortcut
|
if (
|
||||||
.map((key) => {
|
!shortcutConfig ||
|
||||||
switch (key.toLowerCase()) {
|
shortcutConfig.scope !== 'renderer' ||
|
||||||
case 'command':
|
!shortcutConfig.enabled ||
|
||||||
return 'meta'
|
shortcutConfig.key.length === 0
|
||||||
case 'commandorcontrol':
|
) {
|
||||||
return isMac ? 'meta' : 'ctrl'
|
return null
|
||||||
default:
|
}
|
||||||
return key.toLowerCase()
|
return toHotkeysFormat(shortcutConfig.key)
|
||||||
}
|
}, [shortcutConfig])
|
||||||
})
|
|
||||||
.join('+')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
|
|
||||||
|
|
||||||
useHotkeys(
|
useHotkeys(
|
||||||
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
|
hotkey ?? 'none',
|
||||||
(e) => {
|
(event) => {
|
||||||
if (options.preventDefault) {
|
if (options.preventDefault) {
|
||||||
e.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
if (options.enabled !== false) {
|
if (options.enabled !== false) {
|
||||||
callback(e)
|
callback(event)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enableOnFormTags: options.enableOnFormTags,
|
enableOnFormTags: options.enableOnFormTags,
|
||||||
description: options.description || shortcutConfig?.key,
|
description: options.description ?? shortcutConfig?.description,
|
||||||
enabled: !!shortcutConfig?.enabled
|
enabled: Boolean(hotkey && shortcutConfig?.enabled)
|
||||||
}
|
},
|
||||||
|
[
|
||||||
|
callback,
|
||||||
|
hotkey,
|
||||||
|
shortcutConfig,
|
||||||
|
options.preventDefault,
|
||||||
|
options.enableOnFormTags,
|
||||||
|
options.enabled,
|
||||||
|
options.description
|
||||||
|
]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShortcuts() {
|
export function useShortcuts() {
|
||||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
const shortcuts = useShortcutMap()
|
||||||
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
|
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) {
|
export function useShortcutConfig(name: string): HydratedShortcut | undefined {
|
||||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
const shortcuts = useShortcutMap()
|
||||||
return shortcut
|
return shortcuts[name]
|
||||||
.map((key) => {
|
}
|
||||||
switch (key.toLowerCase()) {
|
|
||||||
case 'control':
|
export function useShortcutDisplay(name: string) {
|
||||||
return isMac ? '⌃' : 'Ctrl'
|
const shortcut = useShortcutConfig(name)
|
||||||
case 'ctrl':
|
return useMemo(() => {
|
||||||
return isMac ? '⌃' : 'Ctrl'
|
if (!shortcut || !shortcut.enabled || shortcut.key.length === 0) {
|
||||||
case 'command':
|
return ''
|
||||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
}
|
||||||
case 'alt':
|
return toDisplayFormat(shortcut.key)
|
||||||
return isMac ? '⌥' : 'Alt'
|
}, [shortcut])
|
||||||
case 'shift':
|
}
|
||||||
return isMac ? '⇧' : 'Shift'
|
|
||||||
case 'commandorcontrol':
|
async function writeShortcutPreferences(updater: (current: ShortcutPreferenceMap) => ShortcutPreferenceMap) {
|
||||||
return isMac ? '⌘' : 'Ctrl'
|
const current = await preferenceService.get('shortcut.preferences')
|
||||||
default:
|
const next = updater({ ...current })
|
||||||
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
|
await preferenceService.set('shortcut.preferences', next)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.join('+')
|
export async function setShortcutBinding(name: string, keys: string[]) {
|
||||||
}, [])
|
await writeShortcutPreferences((current) => {
|
||||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
const entry: ShortcutPreferenceEntry = { ...current[name] }
|
||||||
const shortcutConfig = shortcuts.find((s) => s.key === key)
|
entry.key = [...keys]
|
||||||
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
|
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
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -200,6 +200,7 @@ const shortcutKeyMap = {
|
|||||||
exit_fullscreen: 'settings.shortcuts.exit_fullscreen',
|
exit_fullscreen: 'settings.shortcuts.exit_fullscreen',
|
||||||
label: 'settings.shortcuts.label',
|
label: 'settings.shortcuts.label',
|
||||||
mini_window: 'settings.shortcuts.mini_window',
|
mini_window: 'settings.shortcuts.mini_window',
|
||||||
|
show_mini_window: 'settings.shortcuts.show_mini_window',
|
||||||
new_topic: 'settings.shortcuts.new_topic',
|
new_topic: 'settings.shortcuts.new_topic',
|
||||||
press_shortcut: 'settings.shortcuts.press_shortcut',
|
press_shortcut: 'settings.shortcuts.press_shortcut',
|
||||||
reset_defaults: 'settings.shortcuts.reset_defaults',
|
reset_defaults: 'settings.shortcuts.reset_defaults',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { loggerService } from '@logger'
|
|||||||
|
|
||||||
import { startAutoSync } from './services/BackupService'
|
import { startAutoSync } from './services/BackupService'
|
||||||
import { startNutstoreAutoSync } from './services/NutstoreService'
|
import { startNutstoreAutoSync } from './services/NutstoreService'
|
||||||
|
import { initializeShortcutService } from './services/ShortcutService'
|
||||||
import storeSyncService from './services/StoreSyncService'
|
import storeSyncService from './services/StoreSyncService'
|
||||||
import { webTraceService } from './services/WebTraceService'
|
import { webTraceService } from './services/WebTraceService'
|
||||||
loggerService.initWindowSource('mainWindow')
|
loggerService.initWindowSource('mainWindow')
|
||||||
@ -36,3 +37,4 @@ function initWebTrace() {
|
|||||||
initAutoSync()
|
initAutoSync()
|
||||||
initStoreSync()
|
initStoreSync()
|
||||||
initWebTrace()
|
initWebTrace()
|
||||||
|
initializeShortcutService()
|
||||||
|
|||||||
@ -2,12 +2,17 @@ import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
|
|||||||
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
|
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
|
||||||
import { isMac, isWin } from '@renderer/config/constant'
|
import { isMac, isWin } from '@renderer/config/constant'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
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 { useTimer } from '@renderer/hooks/useTimer'
|
||||||
import { getShortcutLabel } from '@renderer/i18n/label'
|
import { getShortcutLabel } from '@renderer/i18n/label'
|
||||||
import { useAppDispatch } from '@renderer/store'
|
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||||
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
|
import type { HydratedShortcut } from '@shared/shortcuts/types'
|
||||||
import type { Shortcut } from '@renderer/types'
|
|
||||||
import type { InputRef } from 'antd'
|
import type { InputRef } from 'antd'
|
||||||
import { Input, Table as AntTable } from 'antd'
|
import { Input, Table as AntTable } from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
@ -18,10 +23,11 @@ import styled from 'styled-components'
|
|||||||
|
|
||||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
||||||
|
|
||||||
|
const definitionMap = new Map(shortcutDefinitions.map((definition) => [definition.name, definition]))
|
||||||
|
|
||||||
const ShortcutSettings: FC = () => {
|
const ShortcutSettings: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
const { shortcuts: originalShortcuts } = useShortcuts()
|
const { shortcuts: originalShortcuts } = useShortcuts()
|
||||||
const inputRefs = useRef<Record<string, InputRef>>({})
|
const inputRefs = useRef<Record<string, InputRef>>({})
|
||||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||||
@ -32,44 +38,34 @@ const ShortcutSettings: FC = () => {
|
|||||||
if (!isWin && !isMac) {
|
if (!isWin && !isMac) {
|
||||||
//Selection Assistant only available on Windows now
|
//Selection Assistant only available on Windows now
|
||||||
const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text']
|
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) => {
|
const handleClear = (record: HydratedShortcut) => {
|
||||||
dispatch(
|
void setShortcutBinding(record.name, [])
|
||||||
updateShortcut({
|
|
||||||
...record,
|
|
||||||
shortcut: []
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddShortcut = (record: Shortcut) => {
|
const handleAddShortcut = (record: HydratedShortcut) => {
|
||||||
setEditingKey(record.key)
|
setEditingKey(record.name)
|
||||||
setTimeoutTimer(
|
setTimeoutTimer(
|
||||||
'handleAddShortcut',
|
'handleAddShortcut',
|
||||||
() => {
|
() => {
|
||||||
inputRefs.current[record.key]?.focus()
|
inputRefs.current[record.name]?.focus()
|
||||||
},
|
},
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isShortcutModified = (record: Shortcut) => {
|
const isShortcutModified = (record: HydratedShortcut) => {
|
||||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
const definition = definitionMap.get(record.name)
|
||||||
return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+')
|
if (!definition) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return definition.defaultKey.join('+') !== record.key.join('+')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResetShortcut = (record: Shortcut) => {
|
const handleResetShortcut = (record: HydratedShortcut) => {
|
||||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
void resetShortcut(record.name)
|
||||||
if (defaultShortcut) {
|
|
||||||
dispatch(
|
|
||||||
updateShortcut({
|
|
||||||
...record,
|
|
||||||
shortcut: defaultShortcut.shortcut
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidShortcut = (keys: string[]): boolean => {
|
const isValidShortcut = (keys: string[]): boolean => {
|
||||||
@ -86,9 +82,10 @@ const ShortcutSettings: FC = () => {
|
|||||||
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
|
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => {
|
const isDuplicateShortcut = (newShortcut: string[], currentName: string): boolean => {
|
||||||
return shortcuts.some(
|
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) => {
|
const handleKeyDown = (event: React.KeyboardEvent, record: HydratedShortcut) => {
|
||||||
e.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
const keys: string[] = []
|
const keys: string[] = []
|
||||||
|
|
||||||
@ -286,12 +283,12 @@ const ShortcutSettings: FC = () => {
|
|||||||
// NEW WAY FOR MODIFIER KEYS
|
// NEW WAY FOR MODIFIER KEYS
|
||||||
// for capability across platforms, we transform the modifier keys to the really meaning keys
|
// for capability across platforms, we transform the modifier keys to the really meaning keys
|
||||||
// mainly consider the habit of users on different platforms
|
// 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 (event.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 (event.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 (event.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.shiftKey) keys.push('Shift')
|
||||||
|
|
||||||
const endKey = usableEndKeys(e)
|
const endKey = usableEndKeys(event)
|
||||||
if (endKey) {
|
if (endKey) {
|
||||||
keys.push(endKey)
|
keys.push(endKey)
|
||||||
}
|
}
|
||||||
@ -300,11 +297,11 @@ const ShortcutSettings: FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDuplicateShortcut(keys, record.key)) {
|
if (isDuplicateShortcut(keys, record.name)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(updateShortcut({ ...record, shortcut: keys }))
|
void setShortcutBinding(record.name, keys)
|
||||||
setEditingKey(null)
|
setEditingKey(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -312,26 +309,28 @@ const ShortcutSettings: FC = () => {
|
|||||||
window.modal.confirm({
|
window.modal.confirm({
|
||||||
title: t('settings.shortcuts.reset_defaults_confirm'),
|
title: t('settings.shortcuts.reset_defaults_confirm'),
|
||||||
centered: true,
|
centered: true,
|
||||||
onOk: () => dispatch(resetShortcuts())
|
onOk: () => resetAllShortcuts()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 由于启用了showHeader = false,不再需要title字段
|
// 由于启用了showHeader = false,不再需要title字段
|
||||||
const columns: ColumnsType<Shortcut> = [
|
type ShortcutRow = HydratedShortcut & { displayName: string; shortcut: string[] }
|
||||||
|
|
||||||
|
const columns: ColumnsType<ShortcutRow> = [
|
||||||
{
|
{
|
||||||
// title: t('settings.shortcuts.action'),
|
// title: t('settings.shortcuts.action'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'displayName',
|
||||||
key: 'name'
|
key: 'displayName'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// title: t('settings.shortcuts.label'),
|
// title: t('settings.shortcuts.label'),
|
||||||
dataIndex: 'shortcut',
|
dataIndex: 'shortcut',
|
||||||
key: 'shortcut',
|
key: 'shortcut',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
render: (shortcut: string[], record: Shortcut) => {
|
render: (_: string[], record: ShortcutRow) => {
|
||||||
const isEditing = editingKey === record.key
|
const isEditing = editingKey === record.name
|
||||||
const shortcutConfig = shortcuts.find((s) => s.key === record.key)
|
const isEditable = record.editable !== false
|
||||||
const isEditable = shortcutConfig?.editable !== false
|
const shortcutKeys = record.key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowFlex className="items-center justify-end gap-2">
|
<RowFlex className="items-center justify-end gap-2">
|
||||||
@ -340,10 +339,10 @@ const ShortcutSettings: FC = () => {
|
|||||||
<ShortcutInput
|
<ShortcutInput
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
if (el) {
|
if (el) {
|
||||||
inputRefs.current[record.key] = el
|
inputRefs.current[record.name] = el
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
value={formatShortcut(shortcut)}
|
value={formatShortcut(shortcutKeys)}
|
||||||
placeholder={t('settings.shortcuts.press_shortcut')}
|
placeholder={t('settings.shortcuts.press_shortcut')}
|
||||||
onKeyDown={(e) => handleKeyDown(e, record)}
|
onKeyDown={(e) => handleKeyDown(e, record)}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
@ -355,7 +354,7 @@ const ShortcutSettings: FC = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
|
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
|
||||||
{shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')}
|
{shortcutKeys.length > 0 ? formatShortcut(shortcutKeys) : t('settings.shortcuts.press_shortcut')}
|
||||||
</ShortcutText>
|
</ShortcutText>
|
||||||
)}
|
)}
|
||||||
</RowFlex>
|
</RowFlex>
|
||||||
@ -368,7 +367,7 @@ const ShortcutSettings: FC = () => {
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: '70px',
|
width: '70px',
|
||||||
render: (record: Shortcut) => (
|
render: (record: ShortcutRow) => (
|
||||||
<RowFlex className="items-center justify-end gap-2">
|
<RowFlex className="items-center justify-end gap-2">
|
||||||
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
|
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
|
||||||
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
|
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
|
||||||
@ -391,8 +390,14 @@ const ShortcutSettings: FC = () => {
|
|||||||
key: 'enabled',
|
key: 'enabled',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
width: '50px',
|
width: '50px',
|
||||||
render: (record: Shortcut) => (
|
render: (record: ShortcutRow) => (
|
||||||
<Switch size="sm" isSelected={record.enabled} onValueChange={() => dispatch(toggleShortcut(record.key))} />
|
<Switch
|
||||||
|
size="sm"
|
||||||
|
isSelected={record.enabled}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
void setShortcutEnabled(record.name, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -404,7 +409,12 @@ const ShortcutSettings: FC = () => {
|
|||||||
<SettingDivider style={{ marginBottom: 0 }} />
|
<SettingDivider style={{ marginBottom: 0 }} />
|
||||||
<Table
|
<Table
|
||||||
columns={columns as ColumnsType<unknown>}
|
columns={columns as ColumnsType<unknown>}
|
||||||
dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))}
|
dataSource={shortcuts.map((shortcut) => ({
|
||||||
|
...shortcut,
|
||||||
|
shortcut: shortcut.key,
|
||||||
|
displayName: getShortcutLabel(shortcut.name)
|
||||||
|
}))}
|
||||||
|
rowKey="name"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="middle"
|
size="middle"
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
|
|||||||
87
src/renderer/src/services/ShortcutService.ts
Normal file
87
src/renderer/src/services/ShortcutService.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { loggerService } from '@logger'
|
||||||
|
import { IpcChannel } from '@shared/IpcChannel'
|
||||||
|
import { shortcutDefinitions } from '@shared/shortcuts/definitions'
|
||||||
|
import type { HydratedShortcutMap } from '@shared/shortcuts/types'
|
||||||
|
|
||||||
|
const logger = loggerService.withContext('RendererShortcutService')
|
||||||
|
|
||||||
|
type ShortcutListener = () => void
|
||||||
|
|
||||||
|
let shortcutsState: HydratedShortcutMap = buildDefaultState()
|
||||||
|
const listeners = new Set<ShortcutListener>()
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
function buildDefaultState(): HydratedShortcutMap {
|
||||||
|
return Object.fromEntries(
|
||||||
|
shortcutDefinitions.map((definition) => [
|
||||||
|
definition.name,
|
||||||
|
{
|
||||||
|
...definition,
|
||||||
|
key: [...definition.defaultKey],
|
||||||
|
enabled: definition.defaultEnabled
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange() {
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Shortcut listener threw an error:', error as Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShortcuts(next: HydratedShortcutMap) {
|
||||||
|
shortcutsState = Object.fromEntries(
|
||||||
|
Object.entries(next).map(([name, config]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
key: [...config.key]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
)
|
||||||
|
emitChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: ShortcutListener): () => void {
|
||||||
|
listeners.add(listener)
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getShortcutsSnapshot(): HydratedShortcutMap {
|
||||||
|
return shortcutsState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initializeShortcutService() {
|
||||||
|
if (initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on(IpcChannel.Shortcuts_Updated, (_event, payload: HydratedShortcutMap) => {
|
||||||
|
setShortcuts(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.api.shortcuts
|
||||||
|
.getAll()
|
||||||
|
.then((payload: HydratedShortcutMap) => {
|
||||||
|
setShortcuts(payload)
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.warn('Failed to load shortcuts from main process, using defaults.', error as Error)
|
||||||
|
setShortcuts(buildDefaultState())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shortcutRendererStore = {
|
||||||
|
subscribe,
|
||||||
|
getSnapshot: getShortcutsSnapshot,
|
||||||
|
getServerSnapshot: getShortcutsSnapshot
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user