From 7dacd588215007e9c52b57a5cf0989c8cc3e4b62 Mon Sep 17 00:00:00 2001 From: kangfenmao Date: Mon, 2 Dec 2024 16:23:40 +0800 Subject: [PATCH] feat: add shortcut feature --- electron.vite.config.ts | 2 +- package.json | 1 + src/main/index.ts | 4 +- src/main/ipc.ts | 13 +- src/main/services/ConfigManager.ts | 9 + src/main/services/ShortcutService.ts | 143 +++++++----- src/preload/index.d.ts | 3 + src/preload/index.ts | 7 +- src/renderer/src/hooks/useShortcuts.ts | 55 +++++ src/renderer/src/i18n/locales/en-us.json | 9 +- src/renderer/src/i18n/locales/ru-ru.json | 9 +- src/renderer/src/i18n/locales/zh-cn.json | 11 +- src/renderer/src/i18n/locales/zh-tw.json | 9 +- .../src/pages/home/Inputbar/Inputbar.tsx | 20 +- .../src/pages/settings/ShortcutSettings.tsx | 214 +++++++++++++----- src/renderer/src/store/index.ts | 4 +- src/renderer/src/store/shortcuts.ts | 74 ++++++ src/renderer/src/types/index.ts | 7 + yarn.lock | 11 + 19 files changed, 470 insertions(+), 135 deletions(-) create mode 100644 src/renderer/src/hooks/useShortcuts.ts create mode 100644 src/renderer/src/store/shortcuts.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index f96e1227..accab509 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ }, plugins: [react()], optimizeDeps: { - exclude: ['chunk-KNVOMWSO.js'] + exclude: ['chunk-KNVOMWSO.js', 'chunk-2NJP6ETL.js'] } } }) diff --git a/package.json b/package.json index 64c7ba53..2d36e0de 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "prettier": "^3.2.4", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hotkeys-hook": "^4.6.1", "react-i18next": "^14.1.2", "react-markdown": "^9.0.1", "react-redux": "^9.1.2", diff --git a/src/main/index.ts b/src/main/index.ts index 1ab36849..466f1761 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,7 +3,7 @@ import { app, BrowserWindow } from 'electron' import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer' import { registerIpc } from './ipc' -import { registerZoomShortcut } from './services/ShortcutService' +import { registerShortcuts } from './services/ShortcutService' import { TrayService } from './services/TrayService' import { windowService } from './services/WindowService' import { updateUserDataPath } from './utils/upgrade' @@ -35,7 +35,7 @@ if (!app.requestSingleInstanceLock()) { } }) - registerZoomShortcut(mainWindow) + registerShortcuts(mainWindow) registerIpc(mainWindow, app) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index a49ebbae..90303677 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -import { ThemeMode } from '@types' +import { Shortcut, ThemeMode } from '@types' import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron' import log from 'electron-log' @@ -11,6 +11,7 @@ import BackupManager from './services/BackupManager' import { configManager } from './services/ConfigManager' import { ExportService } from './services/ExportService' import FileStorage from './services/FileStorage' +import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService' import { windowService } from './services/WindowService' import { compress, decompress } from './utils/zip' @@ -133,4 +134,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle('open:path', async (_, path: string) => { await shell.openPath(path) }) + + // shortcuts + ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => { + configManager.setShortcuts(shortcuts) + // Refresh shortcuts registration + if (mainWindow) { + unregisterAllShortcuts() + registerShortcuts(mainWindow) + } + }) } diff --git a/src/main/services/ConfigManager.ts b/src/main/services/ConfigManager.ts index 2bec8ea6..bf185b16 100644 --- a/src/main/services/ConfigManager.ts +++ b/src/main/services/ConfigManager.ts @@ -70,6 +70,15 @@ export class ConfigManager { subscribers.forEach((subscriber) => subscriber(newValue)) } } + + getShortcuts() { + return this.store.get('shortcuts') as Shortcut[] | undefined + } + + setShortcuts(shortcuts: Shortcut[]) { + this.store.set('shortcuts', shortcuts) + this.notifySubscribers('shortcuts', shortcuts) + } } export const configManager = new ConfigManager() diff --git a/src/main/services/ShortcutService.ts b/src/main/services/ShortcutService.ts index 2e99bc82..1833032a 100644 --- a/src/main/services/ShortcutService.ts +++ b/src/main/services/ShortcutService.ts @@ -1,70 +1,103 @@ +import { Shortcut } from '@types' import { BrowserWindow, globalShortcut } from 'electron' +import Logger from 'electron-log' import { configManager } from './ConfigManager' -export function registerZoomShortcut(mainWindow: BrowserWindow) { - // 初始化缩放值 - const initialZoom = configManager.getZoomFactor() - mainWindow.webContents.setZoomFactor(initialZoom) +let showAppAccelerator: string | null = null - const handleZoom = (delta: number) => { - if (mainWindow) { - const currentZoom = mainWindow.webContents.getZoomFactor() - const newZoom = currentZoom + delta - if (newZoom >= 0.1 && newZoom <= 5.0) { - mainWindow.webContents.setZoomFactor(newZoom) - configManager.setZoomFactor(newZoom) - } - } - } - - const registerShortcuts = () => { - // 放大快捷键 - globalShortcut.register('CommandOrControl+=', () => handleZoom(0.1)) - globalShortcut.register('CommandOrControl+numadd', () => handleZoom(0.1)) - - // 缩小快捷键 - globalShortcut.register('CommandOrControl+-', () => handleZoom(-0.1)) - globalShortcut.register('CommandOrControl+numsub', () => handleZoom(-0.1)) - - // 重置快捷键 - globalShortcut.register('CommandOrControl+0', () => { - if (mainWindow) { - mainWindow.webContents.setZoomFactor(1) +function getShortcutHandler(shortcut: Shortcut) { + switch (shortcut.key) { + case 'zoom_in': + return () => handleZoom(0.1) + case 'zoom_out': + return () => handleZoom(-0.1) + case 'zoom_reset': + return (window: BrowserWindow) => { + window.webContents.setZoomFactor(1) configManager.setZoomFactor(1) } + case 'show_app': + return (window: BrowserWindow) => { + if (window.isVisible()) { + window.hide() + } else { + window.show() + window.focus() + } + } + default: + return null + } +} + +function formatShortcutKey(shortcut: string[]): string { + return shortcut.join('+') +} + +function handleZoom(delta: number) { + return (window: BrowserWindow) => { + const currentZoom = window.webContents.getZoomFactor() + const newZoom = currentZoom + delta + if (newZoom >= 0.1 && newZoom <= 5.0) { + window.webContents.setZoomFactor(newZoom) + configManager.setZoomFactor(newZoom) + } + } +} + +function registerWindowShortcuts(window: BrowserWindow) { + window.webContents.setZoomFactor(configManager.getZoomFactor()) + + const register = () => { + if (window.isDestroyed()) return + + const shortcuts = configManager.getShortcuts() + if (!shortcuts) return + + shortcuts.forEach((shortcut) => { + if (!shortcut.enabled || shortcut.shortcut.length === 0) return + + const handler = getShortcutHandler(shortcut) + if (!handler) return + + const accelerator = formatShortcutKey(shortcut.shortcut) + + if (shortcut.key === 'show_app') { + showAppAccelerator = accelerator + } + + Logger.info(`Register shortcut: ${accelerator}`) + globalShortcut.register(accelerator, () => handler(window)) }) } - const unregisterShortcuts = () => { - globalShortcut.unregister('CommandOrControl+=') - globalShortcut.unregister('CommandOrControl+numadd') - globalShortcut.unregister('CommandOrControl+-') - globalShortcut.unregister('CommandOrControl+numsub') - globalShortcut.unregister('CommandOrControl+0') + const unregister = () => { + if (window.isDestroyed()) return + + globalShortcut.unregisterAll() + + if (showAppAccelerator) { + const handler = getShortcutHandler({ key: 'show_app' } as Shortcut) + if (handler) { + globalShortcut.register(showAppAccelerator, () => handler(window)) + } + } } - // Add check for window destruction - if (mainWindow.isDestroyed()) { - return - } + window.on('focus', () => register()) + window.on('blur', () => unregister()) - // When window gains focus, register shortcuts - mainWindow.on('focus', () => { - if (!mainWindow.isDestroyed()) { - registerShortcuts() - } - }) - - // When window loses focus, unregister shortcuts - mainWindow.on('blur', () => { - if (!mainWindow.isDestroyed()) { - unregisterShortcuts() - } - }) - - // Initial registration (if window is already focused) - if (!mainWindow.isDestroyed() && mainWindow.isFocused()) { - registerShortcuts() + if (!window.isDestroyed() && window.isFocused()) { + register() } } + +export function registerShortcuts(mainWindow: BrowserWindow) { + registerWindowShortcuts(mainWindow) +} + +export function unregisterAllShortcuts() { + showAppAccelerator = null + globalShortcut.unregisterAll() +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5ca6dd66..0ca94655 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -54,6 +54,9 @@ declare global { toWord: (markdown: string, fileName: string) => Promise } openPath: (path: string) => Promise + shortcuts: { + update: (shortcuts: Shortcut[]) => Promise + } } } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 823bb697..c625a3ac 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,5 @@ import { electronAPI } from '@electron-toolkit/preload' -import { WebDavConfig } from '@types' +import { Shortcut, WebDavConfig } from '@types' import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron' // Custom APIs for renderer @@ -47,7 +47,10 @@ const api = { export: { toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName) }, - openPath: (path: string) => ipcRenderer.invoke('open:path', path) + openPath: (path: string) => ipcRenderer.invoke('open:path', path), + shortcuts: { + update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts) + } } // Use `contextBridge` APIs to expose Electron APIs to diff --git a/src/renderer/src/hooks/useShortcuts.ts b/src/renderer/src/hooks/useShortcuts.ts new file mode 100644 index 00000000..754399b5 --- /dev/null +++ b/src/renderer/src/hooks/useShortcuts.ts @@ -0,0 +1,55 @@ +import { useAppSelector } from '@renderer/store' +import { useCallback } from 'react' +import { useHotkeys } from 'react-hotkeys-hook' + +interface UseShortcutOptions { + preventDefault?: boolean + enableOnFormTags?: boolean + enabled?: boolean + description?: string +} + +const defaultOptions: UseShortcutOptions = { + preventDefault: true, + enableOnFormTags: true, + enabled: true +} + +export const useShortcut = ( + shortcutKey: string, + callback: (e: KeyboardEvent) => void, + options: UseShortcutOptions = defaultOptions +) => { + const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) + + const formatShortcut = useCallback((shortcut: string[]) => { + return shortcut + .map((key) => { + switch (key.toLowerCase()) { + case 'command': + return 'meta' + default: + return key.toLowerCase() + } + }) + .join('+') + }, []) + + const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey) + + useHotkeys( + shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : '', + (e) => { + if (options.preventDefault) { + e.preventDefault() + } + if (options.enabled !== false) { + callback(e) + } + }, + { + enableOnFormTags: options.enableOnFormTags, + description: options.description || shortcutConfig?.name + } + ) +} diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 19cc49bd..51759cb9 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -142,7 +142,9 @@ "select": "Select", "topics": "Topics", "warning": "Warning", - "you": "You" + "you": "You", + "clear": "Clear", + "add": "Add" }, "error": { "backup.file_format": "Backup file format error", @@ -451,7 +453,10 @@ "title": "Keyboard Shortcuts", "zoom_in": "Zoom In", "zoom_out": "Zoom Out", - "zoom_reset": "Reset Zoom" + "zoom_reset": "Reset Zoom", + "show_app": "Show App", + "reset_defaults": "Reset Defaults", + "press_shortcut": "Press Shortcut" }, "theme.auto": "Auto", "theme.dark": "Dark", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 1b32e31a..ba994520 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -142,7 +142,9 @@ "select": "Выбрать", "topics": "Топики", "warning": "Предупреждение", - "you": "Вы" + "you": "Вы", + "clear": "Очистить", + "add": "Добавить" }, "error": { "backup.file_format": "Ошибка формата файла резервной копии", @@ -451,7 +453,10 @@ "title": "Горячие клавиши", "zoom_in": "Увеличить", "zoom_out": "Уменьшить", - "zoom_reset": "Сбросить масштаб" + "zoom_reset": "Сбросить масштаб", + "show_app": "Показать приложение", + "reset_defaults": "Сбросить настройки по умолчанию", + "press_shortcut": "Нажмите сочетание клавиш" }, "theme.auto": "Автоматически", "theme.dark": "Темная", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index ff0c78f6..cb811af1 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -142,7 +142,9 @@ "select": "选择", "topics": "话题", "warning": "警告", - "you": "用户" + "you": "用户", + "clear": "清除", + "add": "添加" }, "error": { "backup.file_format": "备份文件格式错误", @@ -436,10 +438,13 @@ "action": "操作", "key": "按键", "new_topic": "新建话题", - "title": "快捷方式", + "title": "快捷键", "zoom_in": "放大界面", "zoom_out": "缩小界面", - "zoom_reset": "重置缩放" + "zoom_reset": "重置缩放", + "show_app": "显示应用", + "reset_defaults": "重置默认快捷键", + "press_shortcut": "按下快捷键" }, "theme.auto": "跟随系统", "theme.dark": "深色主题", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 00864b0a..cc1375c5 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -142,7 +142,9 @@ "select": "選擇", "topics": "話題", "warning": "警告", - "you": "您" + "you": "您", + "clear": "清除", + "add": "添加" }, "error": { "backup.file_format": "備份文件格式錯誤", @@ -439,7 +441,10 @@ "title": "快速方式", "zoom_in": "放大界面", "zoom_out": "縮小界面", - "zoom_reset": "重置縮放" + "zoom_reset": "重置縮放", + "show_app": "顯示應用", + "reset_defaults": "重置預設快捷鍵", + "press_shortcut": "按下快捷鍵" }, "theme.auto": "自動", "theme.dark": "深色主題", diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index dace0db0..d68fa0fb 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -15,6 +15,7 @@ import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' import { useRuntime } from '@renderer/hooks/useRuntime' import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings' +import { useShortcut } from '@renderer/hooks/useShortcuts' import { useShowTopics } from '@renderer/hooks/useStore' import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService' import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' @@ -316,20 +317,13 @@ const Inputbar: FC = ({ assistant, setActiveTopic }) => { setTimeout(() => resizeTextArea(), 0) } - // Command or Ctrl + N create new topic - useEffect(() => { - const onKeydown = (e) => { - if (!generating) { - if ((e.ctrlKey || e.metaKey) && e.key === 'n') { - addNewTopic() - EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) - textareaRef.current?.focus() - } - } + useShortcut('new_topic', () => { + if (!generating) { + addNewTopic() + EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) + textareaRef.current?.focus() } - document.addEventListener('keydown', onKeydown) - return () => document.removeEventListener('keydown', onKeydown) - }, [addNewTopic, generating]) + }) useEffect(() => { const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true }) diff --git a/src/renderer/src/pages/settings/ShortcutSettings.tsx b/src/renderer/src/pages/settings/ShortcutSettings.tsx index e813a564..1fe26f7e 100644 --- a/src/renderer/src/pages/settings/ShortcutSettings.tsx +++ b/src/renderer/src/pages/settings/ShortcutSettings.tsx @@ -1,8 +1,11 @@ +import { UndoOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { useTheme } from '@renderer/context/ThemeProvider' -import { Switch, Table as AntTable, Tag } from 'antd' +import { useAppDispatch, useAppSelector } from '@renderer/store' +import { initialState, resetShortcuts, updateShortcut } from '@renderer/store/shortcuts' +import { Button, Input, InputRef, Table as AntTable } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { FC } from 'react' +import { FC, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' @@ -11,74 +14,179 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.' interface ShortcutItem { key: string name: string - shortcut: string + shortcut: string[] enabled: boolean } const ShortcutSettings: FC = () => { const { t } = useTranslation() const { theme } = useTheme() + const dispatch = useAppDispatch() + const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts) + const inputRefs = useRef>({}) + const [editingKey, setEditingKey] = useState(null) - const commandKey = isMac ? '⌘' : 'Ctrl' + const handleClear = (record: ShortcutItem) => { + dispatch( + updateShortcut({ + ...record, + shortcut: [] + }) + ) + } + + const handleAddShortcut = (record: ShortcutItem) => { + setEditingKey(record.key) + setTimeout(() => { + inputRefs.current[record.key]?.focus() + }, 0) + } + + const isShortcutModified = (record: ShortcutItem) => { + const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) + return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+') + } + + const handleResetShortcut = (record: ShortcutItem) => { + const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key) + if (defaultShortcut) { + dispatch( + updateShortcut({ + ...record, + shortcut: defaultShortcut.shortcut + }) + ) + } + } + + const isValidShortcut = (keys: string[]): boolean => { + const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key)) + const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key)) + return hasModifier && hasNonModifier && keys.length >= 2 + } + + const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => { + return shortcuts.some( + (s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+') + ) + } + + const formatShortcut = (shortcut: string[]): string => { + return shortcut + .map((key) => { + switch (key) { + case 'Control': + return isMac ? '⌃' : 'Ctrl' + case 'Ctrl': + return isMac ? '⌃' : 'Ctrl' + case 'Command': + return '⌘' + case 'Alt': + return isMac ? '⌥' : 'Alt' + case 'Shift': + return isMac ? '⇧' : 'Shift' + case ' ': + return 'Space' + default: + return key.charAt(0).toUpperCase() + key.slice(1) + } + }) + .join(' + ') + } + + const handleKeyDown = (e: React.KeyboardEvent, record: ShortcutItem) => { + e.preventDefault() + + const keys: string[] = [] + if (e.ctrlKey) keys.push(isMac ? 'Control' : 'Ctrl') + if (e.metaKey) keys.push('Command') + if (e.altKey) keys.push('Alt') + if (e.shiftKey) keys.push('Shift') + + const key = e.key + if (!['Control', 'Alt', 'Shift', 'Meta'].includes(key)) { + keys.push(key.toUpperCase()) + } + + if (!isValidShortcut(keys)) { + return + } + + if (isDuplicateShortcut(keys, record.key)) { + return + } + + dispatch( + updateShortcut({ + ...record, + shortcut: keys + }) + ) + setEditingKey(null) + } const columns: ColumnsType = [ { title: t('settings.shortcuts.action'), dataIndex: 'name', - key: 'name', - width: '50%' + key: 'name' }, { title: t('settings.shortcuts.key'), dataIndex: 'shortcut', key: 'shortcut', - width: '30%', - render: (shortcut: string) => { - const keys = shortcut.split(' ').map((key) => key.trim()) + align: 'right', + render: (shortcut: string[], record: ShortcutItem) => { + const isEditing = editingKey === record.key + return ( - - {keys.map((key) => ( - - {key} - - ))} - +
+
+ {isEditing ? ( + el && (inputRefs.current[record.key] = el)} + value={formatShortcut(shortcut)} + placeholder={t('settings.shortcuts.press_shortcut')} + onKeyDown={(e) => handleKeyDown(e, record)} + onBlur={(e) => { + const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon') + if (!isUndoClick) { + setEditingKey(null) + } + }} + style={{ width: '120px' }} + suffix={ + isShortcutModified(record) && ( + { + handleResetShortcut(record) + setEditingKey(null) + }} + /> + ) + } + /> + ) : ( +
handleAddShortcut(record)}> + {shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')} +
+ )} +
+ +
) } - }, - { - title: '', - key: 'enabled', - width: '20%', - align: 'right', - render: () => - } - ] - - const shortcuts: ShortcutItem[] = [ - { - key: 'new_topic', - name: t('settings.shortcuts.new_topic'), - shortcut: `${commandKey} N`, - enabled: true - }, - { - key: 'zoom_in', - name: t('settings.shortcuts.zoom_in'), - shortcut: `${commandKey} +`, - enabled: true - }, - { - key: 'zoom_out', - name: t('settings.shortcuts.zoom_out'), - shortcut: `${commandKey} -`, - enabled: true - }, - { - key: 'zoom_reset', - name: t('settings.shortcuts.zoom_reset'), - shortcut: `${commandKey} 0`, - enabled: true } ] @@ -89,11 +197,15 @@ const ShortcutSettings: FC = () => { } - dataSource={shortcuts} + dataSource={shortcuts.map((s) => ({ ...s, name: t(s.name) }))} pagination={false} size="middle" showHeader={false} /> + +
+ +
) diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index b471d353..753ff4d2 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -10,6 +10,7 @@ import migrate from './migrate' import paintings from './paintings' import runtime from './runtime' import settings from './settings' +import shortcuts from './shortcuts' const rootReducer = combineReducers({ assistants, @@ -17,7 +18,8 @@ const rootReducer = combineReducers({ paintings, llm, settings, - runtime + runtime, + shortcuts }) const persistedReducer = persistReducer( diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts new file mode 100644 index 00000000..b0524579 --- /dev/null +++ b/src/renderer/src/store/shortcuts.ts @@ -0,0 +1,74 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit' +import { isMac } from '@renderer/config/constant' +import { Shortcut } from '@renderer/types' + +export interface ShortcutsState { + shortcuts: Shortcut[] +} + +const initialState: ShortcutsState = { + shortcuts: [ + { + key: 'new_topic', + name: 'settings.shortcuts.new_topic', + shortcut: [isMac ? 'Command' : 'Ctrl', 'N'], + enabled: true + }, + { + key: 'zoom_in', + name: 'settings.shortcuts.zoom_in', + shortcut: [isMac ? 'Command' : 'Ctrl', '='], + enabled: true + }, + { + key: 'zoom_out', + name: 'settings.shortcuts.zoom_out', + shortcut: [isMac ? 'Command' : 'Ctrl', '-'], + enabled: true + }, + { + key: 'zoom_reset', + name: 'settings.shortcuts.zoom_reset', + shortcut: [isMac ? 'Command' : 'Ctrl', '0'], + enabled: true + }, + { + key: 'show_app', + name: 'settings.shortcuts.show_app', + shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'A'], + enabled: true + } + ] +} + +const getSerializableShortcuts = (shortcuts: Shortcut[]) => { + return shortcuts.map((shortcut) => ({ + key: shortcut.key, + name: shortcut.name, + shortcut: [...shortcut.shortcut], + enabled: shortcut.enabled + })) +} + +const shortcutsSlice = createSlice({ + name: 'shortcuts', + initialState, + reducers: { + updateShortcut: (state, action: PayloadAction) => { + state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload.key ? action.payload : s)) + window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + }, + toggleShortcut: (state, action: PayloadAction) => { + state.shortcuts = state.shortcuts.map((s) => (s.key === action.payload ? { ...s, enabled: !s.enabled } : s)) + window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + }, + resetShortcuts: (state) => { + state.shortcuts = initialState.shortcuts + window.api.shortcuts.update(getSerializableShortcuts(state.shortcuts)) + } + } +}) + +export const { updateShortcut, toggleShortcut, resetShortcuts } = shortcutsSlice.actions +export default shortcutsSlice.reducer +export { initialState } diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index 77e6874e..357ad39e 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -162,3 +162,10 @@ export type AppInfo = { filesPath: string logsPath: string } + +export interface Shortcut { + key: string + name: string + shortcut: string[] + enabled: boolean +} diff --git a/yarn.lock b/yarn.lock index 149a6478..450abc29 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2383,6 +2383,7 @@ __metadata: prettier: "npm:^3.2.4" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" + react-hotkeys-hook: "npm:^4.6.1" react-i18next: "npm:^14.1.2" react-markdown: "npm:^9.0.1" react-redux: "npm:^9.1.2" @@ -9955,6 +9956,16 @@ __metadata: languageName: node linkType: hard +"react-hotkeys-hook@npm:^4.6.1": + version: 4.6.1 + resolution: "react-hotkeys-hook@npm:4.6.1" + peerDependencies: + react: ">=16.8.1" + react-dom: ">=16.8.1" + checksum: 10c0/e23916567b0b863831cf7e2ddba591e82988a3212a9e4b818ac7da6d751b7e8b102f2245d8bf7e9526a97766ffd092efdbfbcc457b74564415f5d5f8ab51919e + languageName: node + linkType: hard + "react-i18next@npm:^14.1.2": version: 14.1.3 resolution: "react-i18next@npm:14.1.3"