feat: add shortcut feature
This commit is contained in:
parent
744a6ac7cb
commit
7dacd58821
@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['chunk-KNVOMWSO.js']
|
exclude: ['chunk-KNVOMWSO.js', 'chunk-2NJP6ETL.js']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -96,6 +96,7 @@
|
|||||||
"prettier": "^3.2.4",
|
"prettier": "^3.2.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hotkeys-hook": "^4.6.1",
|
||||||
"react-i18next": "^14.1.2",
|
"react-i18next": "^14.1.2",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.1.2",
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { app, BrowserWindow } from 'electron'
|
|||||||
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
|
||||||
|
|
||||||
import { registerIpc } from './ipc'
|
import { registerIpc } from './ipc'
|
||||||
import { registerZoomShortcut } from './services/ShortcutService'
|
import { registerShortcuts } from './services/ShortcutService'
|
||||||
import { TrayService } from './services/TrayService'
|
import { TrayService } from './services/TrayService'
|
||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import { updateUserDataPath } from './utils/upgrade'
|
import { updateUserDataPath } from './utils/upgrade'
|
||||||
@ -35,7 +35,7 @@ if (!app.requestSingleInstanceLock()) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
registerZoomShortcut(mainWindow)
|
registerShortcuts(mainWindow)
|
||||||
|
|
||||||
registerIpc(mainWindow, app)
|
registerIpc(mainWindow, app)
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
import { ThemeMode } from '@types'
|
import { Shortcut, ThemeMode } from '@types'
|
||||||
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
|
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import BackupManager from './services/BackupManager'
|
|||||||
import { configManager } from './services/ConfigManager'
|
import { configManager } from './services/ConfigManager'
|
||||||
import { ExportService } from './services/ExportService'
|
import { ExportService } from './services/ExportService'
|
||||||
import FileStorage from './services/FileStorage'
|
import FileStorage from './services/FileStorage'
|
||||||
|
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||||
import { windowService } from './services/WindowService'
|
import { windowService } from './services/WindowService'
|
||||||
import { compress, decompress } from './utils/zip'
|
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) => {
|
ipcMain.handle('open:path', async (_, path: string) => {
|
||||||
await shell.openPath(path)
|
await shell.openPath(path)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// shortcuts
|
||||||
|
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
|
||||||
|
configManager.setShortcuts(shortcuts)
|
||||||
|
// Refresh shortcuts registration
|
||||||
|
if (mainWindow) {
|
||||||
|
unregisterAllShortcuts()
|
||||||
|
registerShortcuts(mainWindow)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,6 +70,15 @@ export class ConfigManager {
|
|||||||
subscribers.forEach((subscriber) => subscriber(newValue))
|
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()
|
export const configManager = new ConfigManager()
|
||||||
|
|||||||
@ -1,70 +1,103 @@
|
|||||||
|
import { Shortcut } from '@types'
|
||||||
import { BrowserWindow, globalShortcut } from 'electron'
|
import { BrowserWindow, globalShortcut } from 'electron'
|
||||||
|
import Logger from 'electron-log'
|
||||||
|
|
||||||
import { configManager } from './ConfigManager'
|
import { configManager } from './ConfigManager'
|
||||||
|
|
||||||
export function registerZoomShortcut(mainWindow: BrowserWindow) {
|
let showAppAccelerator: string | null = null
|
||||||
// 初始化缩放值
|
|
||||||
const initialZoom = configManager.getZoomFactor()
|
|
||||||
mainWindow.webContents.setZoomFactor(initialZoom)
|
|
||||||
|
|
||||||
const handleZoom = (delta: number) => {
|
function getShortcutHandler(shortcut: Shortcut) {
|
||||||
if (mainWindow) {
|
switch (shortcut.key) {
|
||||||
const currentZoom = mainWindow.webContents.getZoomFactor()
|
case 'zoom_in':
|
||||||
const newZoom = currentZoom + delta
|
return () => handleZoom(0.1)
|
||||||
if (newZoom >= 0.1 && newZoom <= 5.0) {
|
case 'zoom_out':
|
||||||
mainWindow.webContents.setZoomFactor(newZoom)
|
return () => handleZoom(-0.1)
|
||||||
configManager.setZoomFactor(newZoom)
|
case 'zoom_reset':
|
||||||
}
|
return (window: BrowserWindow) => {
|
||||||
}
|
window.webContents.setZoomFactor(1)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
configManager.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 = () => {
|
const unregister = () => {
|
||||||
globalShortcut.unregister('CommandOrControl+=')
|
if (window.isDestroyed()) return
|
||||||
globalShortcut.unregister('CommandOrControl+numadd')
|
|
||||||
globalShortcut.unregister('CommandOrControl+-')
|
globalShortcut.unregisterAll()
|
||||||
globalShortcut.unregister('CommandOrControl+numsub')
|
|
||||||
globalShortcut.unregister('CommandOrControl+0')
|
if (showAppAccelerator) {
|
||||||
|
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||||
|
if (handler) {
|
||||||
|
globalShortcut.register(showAppAccelerator, () => handler(window))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add check for window destruction
|
window.on('focus', () => register())
|
||||||
if (mainWindow.isDestroyed()) {
|
window.on('blur', () => unregister())
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// When window gains focus, register shortcuts
|
if (!window.isDestroyed() && window.isFocused()) {
|
||||||
mainWindow.on('focus', () => {
|
register()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function registerShortcuts(mainWindow: BrowserWindow) {
|
||||||
|
registerWindowShortcuts(mainWindow)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterAllShortcuts() {
|
||||||
|
showAppAccelerator = null
|
||||||
|
globalShortcut.unregisterAll()
|
||||||
|
}
|
||||||
|
|||||||
3
src/preload/index.d.ts
vendored
3
src/preload/index.d.ts
vendored
@ -54,6 +54,9 @@ declare global {
|
|||||||
toWord: (markdown: string, fileName: string) => Promise<void>
|
toWord: (markdown: string, fileName: string) => Promise<void>
|
||||||
}
|
}
|
||||||
openPath: (path: string) => Promise<void>
|
openPath: (path: string) => Promise<void>
|
||||||
|
shortcuts: {
|
||||||
|
update: (shortcuts: Shortcut[]) => Promise<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import { WebDavConfig } from '@types'
|
import { Shortcut, WebDavConfig } from '@types'
|
||||||
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
@ -47,7 +47,10 @@ const api = {
|
|||||||
export: {
|
export: {
|
||||||
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
|
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
|
// Use `contextBridge` APIs to expose Electron APIs to
|
||||||
|
|||||||
55
src/renderer/src/hooks/useShortcuts.ts
Normal file
55
src/renderer/src/hooks/useShortcuts.ts
Normal file
@ -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
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -142,7 +142,9 @@
|
|||||||
"select": "Select",
|
"select": "Select",
|
||||||
"topics": "Topics",
|
"topics": "Topics",
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"you": "You"
|
"you": "You",
|
||||||
|
"clear": "Clear",
|
||||||
|
"add": "Add"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"backup.file_format": "Backup file format error",
|
"backup.file_format": "Backup file format error",
|
||||||
@ -451,7 +453,10 @@
|
|||||||
"title": "Keyboard Shortcuts",
|
"title": "Keyboard Shortcuts",
|
||||||
"zoom_in": "Zoom In",
|
"zoom_in": "Zoom In",
|
||||||
"zoom_out": "Zoom Out",
|
"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.auto": "Auto",
|
||||||
"theme.dark": "Dark",
|
"theme.dark": "Dark",
|
||||||
|
|||||||
@ -142,7 +142,9 @@
|
|||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"topics": "Топики",
|
"topics": "Топики",
|
||||||
"warning": "Предупреждение",
|
"warning": "Предупреждение",
|
||||||
"you": "Вы"
|
"you": "Вы",
|
||||||
|
"clear": "Очистить",
|
||||||
|
"add": "Добавить"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"backup.file_format": "Ошибка формата файла резервной копии",
|
"backup.file_format": "Ошибка формата файла резервной копии",
|
||||||
@ -451,7 +453,10 @@
|
|||||||
"title": "Горячие клавиши",
|
"title": "Горячие клавиши",
|
||||||
"zoom_in": "Увеличить",
|
"zoom_in": "Увеличить",
|
||||||
"zoom_out": "Уменьшить",
|
"zoom_out": "Уменьшить",
|
||||||
"zoom_reset": "Сбросить масштаб"
|
"zoom_reset": "Сбросить масштаб",
|
||||||
|
"show_app": "Показать приложение",
|
||||||
|
"reset_defaults": "Сбросить настройки по умолчанию",
|
||||||
|
"press_shortcut": "Нажмите сочетание клавиш"
|
||||||
},
|
},
|
||||||
"theme.auto": "Автоматически",
|
"theme.auto": "Автоматически",
|
||||||
"theme.dark": "Темная",
|
"theme.dark": "Темная",
|
||||||
|
|||||||
@ -142,7 +142,9 @@
|
|||||||
"select": "选择",
|
"select": "选择",
|
||||||
"topics": "话题",
|
"topics": "话题",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "用户"
|
"you": "用户",
|
||||||
|
"clear": "清除",
|
||||||
|
"add": "添加"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"backup.file_format": "备份文件格式错误",
|
"backup.file_format": "备份文件格式错误",
|
||||||
@ -436,10 +438,13 @@
|
|||||||
"action": "操作",
|
"action": "操作",
|
||||||
"key": "按键",
|
"key": "按键",
|
||||||
"new_topic": "新建话题",
|
"new_topic": "新建话题",
|
||||||
"title": "快捷方式",
|
"title": "快捷键",
|
||||||
"zoom_in": "放大界面",
|
"zoom_in": "放大界面",
|
||||||
"zoom_out": "缩小界面",
|
"zoom_out": "缩小界面",
|
||||||
"zoom_reset": "重置缩放"
|
"zoom_reset": "重置缩放",
|
||||||
|
"show_app": "显示应用",
|
||||||
|
"reset_defaults": "重置默认快捷键",
|
||||||
|
"press_shortcut": "按下快捷键"
|
||||||
},
|
},
|
||||||
"theme.auto": "跟随系统",
|
"theme.auto": "跟随系统",
|
||||||
"theme.dark": "深色主题",
|
"theme.dark": "深色主题",
|
||||||
|
|||||||
@ -142,7 +142,9 @@
|
|||||||
"select": "選擇",
|
"select": "選擇",
|
||||||
"topics": "話題",
|
"topics": "話題",
|
||||||
"warning": "警告",
|
"warning": "警告",
|
||||||
"you": "您"
|
"you": "您",
|
||||||
|
"clear": "清除",
|
||||||
|
"add": "添加"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"backup.file_format": "備份文件格式錯誤",
|
"backup.file_format": "備份文件格式錯誤",
|
||||||
@ -439,7 +441,10 @@
|
|||||||
"title": "快速方式",
|
"title": "快速方式",
|
||||||
"zoom_in": "放大界面",
|
"zoom_in": "放大界面",
|
||||||
"zoom_out": "縮小界面",
|
"zoom_out": "縮小界面",
|
||||||
"zoom_reset": "重置縮放"
|
"zoom_reset": "重置縮放",
|
||||||
|
"show_app": "顯示應用",
|
||||||
|
"reset_defaults": "重置預設快捷鍵",
|
||||||
|
"press_shortcut": "按下快捷鍵"
|
||||||
},
|
},
|
||||||
"theme.auto": "自動",
|
"theme.auto": "自動",
|
||||||
"theme.dark": "深色主題",
|
"theme.dark": "深色主題",
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import db from '@renderer/databases'
|
|||||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||||
|
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||||
import { useShowTopics } from '@renderer/hooks/useStore'
|
import { useShowTopics } from '@renderer/hooks/useStore'
|
||||||
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
|
||||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||||
@ -316,20 +317,13 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
|
|||||||
setTimeout(() => resizeTextArea(), 0)
|
setTimeout(() => resizeTextArea(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command or Ctrl + N create new topic
|
useShortcut('new_topic', () => {
|
||||||
useEffect(() => {
|
if (!generating) {
|
||||||
const onKeydown = (e) => {
|
addNewTopic()
|
||||||
if (!generating) {
|
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
|
textareaRef.current?.focus()
|
||||||
addNewTopic()
|
|
||||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
|
||||||
textareaRef.current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKeydown)
|
})
|
||||||
return () => document.removeEventListener('keydown', onKeydown)
|
|
||||||
}, [addNewTopic, generating])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
|
import { UndoOutlined } from '@ant-design/icons'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isMac } from '@renderer/config/constant'
|
||||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { FC } from 'react'
|
import { FC, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -11,74 +14,179 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
|||||||
interface ShortcutItem {
|
interface ShortcutItem {
|
||||||
key: string
|
key: string
|
||||||
name: string
|
name: string
|
||||||
shortcut: string
|
shortcut: string[]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortcutSettings: FC = () => {
|
const ShortcutSettings: FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||||
|
const inputRefs = useRef<Record<string, InputRef>>({})
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(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<ShortcutItem> = [
|
const columns: ColumnsType<ShortcutItem> = [
|
||||||
{
|
{
|
||||||
title: t('settings.shortcuts.action'),
|
title: t('settings.shortcuts.action'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name'
|
||||||
width: '50%'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('settings.shortcuts.key'),
|
title: t('settings.shortcuts.key'),
|
||||||
dataIndex: 'shortcut',
|
dataIndex: 'shortcut',
|
||||||
key: 'shortcut',
|
key: 'shortcut',
|
||||||
width: '30%',
|
align: 'right',
|
||||||
render: (shortcut: string) => {
|
render: (shortcut: string[], record: ShortcutItem) => {
|
||||||
const keys = shortcut.split(' ').map((key) => key.trim())
|
const isEditing = editingKey === record.key
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
{keys.map((key) => (
|
<div style={{ position: 'relative', flex: 1 }}>
|
||||||
<Tag key={key} style={{ padding: '2px 8px', fontSize: '13px' }}>
|
{isEditing ? (
|
||||||
<span style={{ fontFamily: 'monospace' }}>{key}</span>
|
<Input
|
||||||
</Tag>
|
ref={(el) => el && (inputRefs.current[record.key] = el)}
|
||||||
))}
|
value={formatShortcut(shortcut)}
|
||||||
</span>
|
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) && (
|
||||||
|
<UndoOutlined
|
||||||
|
className="shortcut-undo-icon"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: '8px',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#999'
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
handleResetShortcut(record)
|
||||||
|
setEditingKey(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ cursor: 'pointer', padding: '4px 11px' }} onClick={() => handleAddShortcut(record)}>
|
||||||
|
{shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => (shortcut ? handleClear(record) : handleAddShortcut(record))}>
|
||||||
|
{shortcut ? t('common.clear') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '',
|
|
||||||
key: 'enabled',
|
|
||||||
width: '20%',
|
|
||||||
align: 'right',
|
|
||||||
render: () => <Switch defaultChecked disabled />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
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 = () => {
|
|||||||
<SettingDivider style={{ marginBottom: 0 }} />
|
<SettingDivider style={{ marginBottom: 0 }} />
|
||||||
<Table
|
<Table
|
||||||
columns={columns as ColumnsType<unknown>}
|
columns={columns as ColumnsType<unknown>}
|
||||||
dataSource={shortcuts}
|
dataSource={shortcuts.map((s) => ({ ...s, name: t(s.name) }))}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="middle"
|
size="middle"
|
||||||
showHeader={false}
|
showHeader={false}
|
||||||
/>
|
/>
|
||||||
|
<SettingDivider style={{ marginBottom: 0 }} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '16px 0' }}>
|
||||||
|
<Button onClick={() => dispatch(resetShortcuts())}>{t('settings.shortcuts.reset_defaults')}</Button>
|
||||||
|
</div>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
</SettingContainer>
|
</SettingContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import migrate from './migrate'
|
|||||||
import paintings from './paintings'
|
import paintings from './paintings'
|
||||||
import runtime from './runtime'
|
import runtime from './runtime'
|
||||||
import settings from './settings'
|
import settings from './settings'
|
||||||
|
import shortcuts from './shortcuts'
|
||||||
|
|
||||||
const rootReducer = combineReducers({
|
const rootReducer = combineReducers({
|
||||||
assistants,
|
assistants,
|
||||||
@ -17,7 +18,8 @@ const rootReducer = combineReducers({
|
|||||||
paintings,
|
paintings,
|
||||||
llm,
|
llm,
|
||||||
settings,
|
settings,
|
||||||
runtime
|
runtime,
|
||||||
|
shortcuts
|
||||||
})
|
})
|
||||||
|
|
||||||
const persistedReducer = persistReducer(
|
const persistedReducer = persistReducer(
|
||||||
|
|||||||
74
src/renderer/src/store/shortcuts.ts
Normal file
74
src/renderer/src/store/shortcuts.ts
Normal file
@ -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<Shortcut>) => {
|
||||||
|
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<string>) => {
|
||||||
|
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 }
|
||||||
@ -162,3 +162,10 @@ export type AppInfo = {
|
|||||||
filesPath: string
|
filesPath: string
|
||||||
logsPath: string
|
logsPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Shortcut {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
shortcut: string[]
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|||||||
11
yarn.lock
11
yarn.lock
@ -2383,6 +2383,7 @@ __metadata:
|
|||||||
prettier: "npm:^3.2.4"
|
prettier: "npm:^3.2.4"
|
||||||
react: "npm:^18.2.0"
|
react: "npm:^18.2.0"
|
||||||
react-dom: "npm:^18.2.0"
|
react-dom: "npm:^18.2.0"
|
||||||
|
react-hotkeys-hook: "npm:^4.6.1"
|
||||||
react-i18next: "npm:^14.1.2"
|
react-i18next: "npm:^14.1.2"
|
||||||
react-markdown: "npm:^9.0.1"
|
react-markdown: "npm:^9.0.1"
|
||||||
react-redux: "npm:^9.1.2"
|
react-redux: "npm:^9.1.2"
|
||||||
@ -9955,6 +9956,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"react-i18next@npm:^14.1.2":
|
||||||
version: 14.1.3
|
version: 14.1.3
|
||||||
resolution: "react-i18next@npm:14.1.3"
|
resolution: "react-i18next@npm:14.1.3"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user