Compare commits
4 Commits
v2
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03c8b6b5b4 | ||
|
|
f5136a0adb | ||
|
|
99873a0767 | ||
|
|
34affb4533 |
191
docs/technical/ShortcutSystemRefactor.md
Normal file
191
docs/technical/ShortcutSystemRefactor.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Cherry Studio 快捷键系统重构设计文档 v2.1
|
||||
|
||||
> 最近更新:2025-01-30
|
||||
> 维护者:Architecture Team
|
||||
|
||||
## 目录
|
||||
|
||||
- [背景与目标](#背景与目标)
|
||||
- [核心原则](#核心原则)
|
||||
- [架构分层](#架构分层)
|
||||
- [关键实现](#关键实现)
|
||||
- [数据流](#数据流)
|
||||
- [默认快捷键](#默认快捷键)
|
||||
- [迁移与兼容性](#迁移与兼容性)
|
||||
- [后续演进方向](#后续演进方向)
|
||||
|
||||
---
|
||||
|
||||
## 背景与目标
|
||||
|
||||
旧版快捷键系统存在以下问题:
|
||||
|
||||
1. 依赖已弃用的 `configManager`,与 v2 架构不兼容;
|
||||
2. Redux store 与本地存储重复维护状态;
|
||||
3. 处理器通过 `switch-case` 硬编码,可维护性差;
|
||||
4. 快捷键定义分散,缺乏统一真相源;
|
||||
5. 新增快捷键需要触达多处文件,易错且低效。
|
||||
|
||||
新版系统要实现:
|
||||
|
||||
- **单一真相源**:快捷键定义集中管理,保证一致性;
|
||||
- **偏好服务优先**:所有运行时状态通过 `preferenceService` 管理;
|
||||
- **处理器注册表**:解除 `switch-case` 依赖,改用 Map 注册;
|
||||
- **类型安全**:从定义、存储到消费全链路具备 TypeScript 约束;
|
||||
- **易扩展**:新增快捷键仅需「定义 → 注册处理器 → 使用」三步;
|
||||
- **性能稳定**:支持 100+ 快捷键规模,主/渲染进程高效同步;
|
||||
- **多窗口同步**:借助 `preferenceService` 自动推送变更。
|
||||
|
||||
---
|
||||
|
||||
## 核心原则
|
||||
|
||||
1. **关注点分离**
|
||||
- 定义层:静态元数据(名称、默认绑定、作用域、分类等);
|
||||
- 偏好层:用户可变配置(绑定、启用状态等);
|
||||
- 服务层:主进程注册、电焦/失焦时的生命周期管理;
|
||||
- UI 层:设置面板、快捷键提示等。
|
||||
|
||||
2. **复用基础设施**
|
||||
- 所有持久化均依赖 `preferenceService`(SQLite + 内存缓存 + IPC);
|
||||
- 变更通过订阅自动广播至所有窗口;
|
||||
- 新增键位无需改动主进程/渲染进程的底层框架代码。
|
||||
|
||||
---
|
||||
|
||||
## 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Shortcut 系统 │
|
||||
├──────────────────────────────────────────────┤
|
||||
│ 📋 Definitions (packages/shared/shortcuts) │
|
||||
│ - types.ts:类型、作用域、分类 │
|
||||
│ - definitions.ts:静态定义(真相之源) │
|
||||
│ - utils.ts:转换/校验工具 │
|
||||
│ │
|
||||
│ 💾 Preferences (preferenceService) │
|
||||
│ - preferenceSchemas.ts 默认值 │
|
||||
│ - preferenceTypes.ts 类型导出 │
|
||||
│ │
|
||||
│ ⚙️ Services │
|
||||
│ - src/main/services/ShortcutService.ts │
|
||||
│ · 处理器注册表、focus/blur 生命周期 │
|
||||
│ · preference 订阅、主进程快捷键注册 │
|
||||
│ - 渲染进程 useShortcut/useShortcutDisplay │
|
||||
│ │
|
||||
│ 🎨 UI │
|
||||
│ - 设置页 ShortcutSettings │
|
||||
│ - 各功能模块中的 useShortcut/useShortcutDisplay │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键实现
|
||||
|
||||
### 1. 静态定义
|
||||
|
||||
- 所有快捷键在 `packages/shared/shortcuts/definitions.ts` 中集中维护;
|
||||
- 包含 `scope`(main / renderer / both)、`category`、`persistOnBlur` 等元信息;
|
||||
- `enabledWhen` 支持动态启用(如 mini window 与 quick assistant 开关关联);
|
||||
- 新增快捷键步骤:
|
||||
1. 在 `preferenceSchemas.ts` 中声明默认值;
|
||||
2. 在 `definitions.ts` 中补充静态定义;
|
||||
3. 在主/渲染进程相关模块注册处理器或消费 Hook。
|
||||
|
||||
### 2. 偏好系统
|
||||
|
||||
- 所有运行时配置通过 `preferenceService` 读写;
|
||||
- 默认值与 `PreferenceShortcutType` 结构保持一致;
|
||||
- `ShortcutService` / `useShortcuts` 访问偏好时统一调用 `coerceShortcutPreference`,确保 fallback 与类型安全;
|
||||
- 批量重置通过 `preferenceService.setMultiple` 实现。
|
||||
|
||||
### 3. 主进程服务
|
||||
|
||||
- `ShortcutService` 负责:
|
||||
- 生命周期:随着窗口 focus/blur 注册或卸载快捷键;
|
||||
- 处理器注册:Map 替换 `switch-case`;
|
||||
- 订阅偏好变更:自动重新注册;
|
||||
- `persistOnBlur`:例如 `show_main_window` 在窗口失焦时仍可触发;
|
||||
- `shortcut.app.show_settings` 会在需要时唤起窗口并调用 `window.navigate('/settings/provider')`,避免重复 blur/focus。
|
||||
|
||||
### 4. 渲染进程 Hook
|
||||
|
||||
- `useShortcut`:从偏好获取绑定 → 转为 `react-hotkeys-hook` 字符串 → 注册快捷键;
|
||||
- `useShortcutDisplay`:转换为 UI 显示字符串(`⌘` / `Ctrl+` 等);
|
||||
- `useAllShortcuts`:批量拉取配置 + diff 默认值,供设置面板使用;
|
||||
- 新增 `enableOnContentEditable` 等配置支撑设置页和富文本场景。
|
||||
|
||||
### 5. 设置界面
|
||||
|
||||
- `ShortcutSettings` 直接消费 `useAllShortcuts`;
|
||||
- 支持录制、清空、重置默认、启用/禁用、冲突检测;
|
||||
- 重新绑定时使用 `convertKeyToAccelerator` / `isValidShortcut` / `formatShortcutDisplay`;
|
||||
- “重置全部” 通过 `preferenceService.setMultiple` 一次性写入默认配置;
|
||||
- 新增表格展示 `hasCustomBinding`,区分用户自定义与继承默认值。
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
### 启动阶段
|
||||
|
||||
1. `preferenceService.initialize()` 载入缓存;
|
||||
2. `shortcutService` 构造时注册处理器与订阅;
|
||||
3. 窗口创建后调用 `shortcutService.registerForWindow`,在 `focus` 时注册主进程快捷键。
|
||||
|
||||
### 运行时变更
|
||||
|
||||
1. 设置页或其他模块调用 `preferenceService.set` / `setMultiple`;
|
||||
2. 主进程订阅触发 → `globalShortcut.unregisterAll()` → 按新配置重注册;
|
||||
3. 渲染进程通过 `usePreference`/`useMultiplePreferences` 自动收到更新,UI 即时刷新。
|
||||
|
||||
---
|
||||
|
||||
## 默认快捷键
|
||||
|
||||
| preference key | 默认绑定 | 描述 / 备注 |
|
||||
|----------------------------------------|-----------------------------|--------------------------------------|
|
||||
| `shortcut.app.show_main_window` | `Cmd/Ctrl + Shift + A` | 主窗口显示(失焦持久) |
|
||||
| `shortcut.app.show_mini_window` | `Cmd/Ctrl + E` | Mini 窗口(与 quick assistant 联动) |
|
||||
| `shortcut.app.show_settings` | `Cmd/Ctrl + ,` | 设置页入口 |
|
||||
| `shortcut.app.toggle_show_assistants` | `Cmd/Ctrl + [` | 助手侧边栏 |
|
||||
| `shortcut.app.exit_fullscreen` | `Escape` | 系统级,不可编辑 |
|
||||
| `shortcut.app.zoom_in/out/reset` | `Cmd/Ctrl + = / - / 0` | 包含数字键盘变体 |
|
||||
| `shortcut.app.search_message` | `Cmd/Ctrl + Shift + F` | 全局搜索 |
|
||||
| `shortcut.chat.clear` | `Cmd/Ctrl + L` | 清空消息 |
|
||||
| `shortcut.chat.search_message` | `Cmd/Ctrl + F` | 聊天内搜索 |
|
||||
| `shortcut.chat.toggle_new_context` | `Cmd/Ctrl + K` | 新上下文 |
|
||||
| `shortcut.chat.copy_last_message` | `Cmd/Ctrl + Shift + C` | 复制最后一条 |
|
||||
| `shortcut.chat.edit_last_user_message` | `Cmd/Ctrl + Shift + E` | 编辑最后一条用户消息 |
|
||||
| `shortcut.topic.new` | `Cmd/Ctrl + N` | 新增话题(默认启用) |
|
||||
| `shortcut.topic.rename` | `Cmd/Ctrl + T` | 重命名话题(默认启用,自 2025-01 调整) |
|
||||
| `shortcut.topic.toggle_show_topics` | `Cmd/Ctrl + ]` | 话题侧边栏 |
|
||||
| `shortcut.selection.*` | 无默认绑定 | 划词助手开关、取词 |
|
||||
|
||||
> 具体配置以 `preferenceSchemas.ts` 为准,可在设置页查看或调整。
|
||||
|
||||
---
|
||||
|
||||
## 迁移与兼容性
|
||||
|
||||
- 已有用户偏好:沿用旧值;新增键(如 `shortcut.topic.rename`)在数据库不存在时继承新默认;
|
||||
- 旧版 Redux store / `configManager` 已彻底移除;
|
||||
- `IpcChannel.Shortcuts_Update` 与 `window.api.shortcuts.update` 相关逻辑已弃用;
|
||||
- `PreferenceMigrator` 中保留与旧 keys 的映射,确保升级顺畅。
|
||||
|
||||
---
|
||||
|
||||
## 后续演进方向
|
||||
|
||||
1. **冲突检测增强**:主/渲染进程联动校验冲突并提示;
|
||||
2. **导入导出**:允许用户批量备份/恢复自定义快捷键;
|
||||
3. **多作用域绑定**:同一逻辑支持按窗口类型或上下文切换;
|
||||
4. **可视化录制**:增加「录制模式」避免输入框手动录制;
|
||||
5. **自动化测试**:补充主进程/渲染进程快捷键单元测试样板。
|
||||
|
||||
---
|
||||
|
||||
> 如需扩展或有疑问,请联系架构团队或在仓库中提交 Issue。
|
||||
> 设计文档 v2.1 同步最新实现(2025-01),包含 `shortcut.topic.rename` 默认启用、`show_settings` 优化等补充说明。
|
||||
@@ -373,6 +373,8 @@ export interface PreferenceSchemas {
|
||||
'shortcut.chat.clear': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.copy_last_message
|
||||
'shortcut.chat.copy_last_message': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.edit_last_user_message
|
||||
'shortcut.chat.edit_last_user_message': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.search_message_in_chat
|
||||
'shortcut.chat.search_message': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.toggle_new_context
|
||||
@@ -383,6 +385,10 @@ export interface PreferenceSchemas {
|
||||
'shortcut.selection.toggle_enabled': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.new_topic
|
||||
'shortcut.topic.new': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.rename_topic
|
||||
'shortcut.topic.rename': Record<string, unknown>
|
||||
// redux/shortcuts/shortcuts.toggle_show_topics
|
||||
'shortcut.topic.toggle_show_topics': Record<string, unknown>
|
||||
// redux/settings/enableTopicNaming
|
||||
'topic.naming.enabled': boolean
|
||||
// redux/settings/topicNamingPrompt
|
||||
@@ -638,6 +644,12 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
key: ['CommandOrControl', 'Shift', 'C'],
|
||||
system: false
|
||||
},
|
||||
'shortcut.chat.edit_last_user_message': {
|
||||
editable: true,
|
||||
enabled: false,
|
||||
key: ['CommandOrControl', 'Shift', 'E'],
|
||||
system: false
|
||||
},
|
||||
'shortcut.chat.search_message': { editable: true, enabled: true, key: ['CommandOrControl', 'F'], system: false },
|
||||
'shortcut.chat.toggle_new_context': {
|
||||
editable: true,
|
||||
@@ -648,6 +660,18 @@ export const DefaultPreferences: PreferenceSchemas = {
|
||||
'shortcut.selection.get_text': { editable: true, enabled: false, key: [], system: true },
|
||||
'shortcut.selection.toggle_enabled': { editable: true, enabled: false, key: [], system: true },
|
||||
'shortcut.topic.new': { editable: true, enabled: true, key: ['CommandOrControl', 'N'], system: false },
|
||||
'shortcut.topic.rename': {
|
||||
editable: true,
|
||||
enabled: true,
|
||||
key: ['CommandOrControl', 'T'],
|
||||
system: false
|
||||
},
|
||||
'shortcut.topic.toggle_show_topics': {
|
||||
editable: true,
|
||||
enabled: true,
|
||||
key: ['CommandOrControl', ']'],
|
||||
system: false
|
||||
},
|
||||
'topic.naming.enabled': true,
|
||||
'topic.naming_prompt': '',
|
||||
'topic.position': 'left',
|
||||
|
||||
148
packages/shared/shortcuts/definitions.ts
Normal file
148
packages/shared/shortcuts/definitions.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { ShortcutCategory, ShortcutDefinition } from './types'
|
||||
|
||||
export const SHORTCUT_DEFINITIONS: readonly ShortcutDefinition[] = [
|
||||
// ==================== 应用级快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.app.show_main_window',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'A'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
persistOnBlur: true
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.show_mini_window',
|
||||
defaultKey: ['CommandOrControl', 'E'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
persistOnBlur: true,
|
||||
enabledWhen: (getPreference) => !!getPreference('feature.quick_assistant.enabled')
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.show_settings',
|
||||
defaultKey: ['CommandOrControl', ','],
|
||||
scope: 'both',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.toggle_show_assistants',
|
||||
defaultKey: ['CommandOrControl', '['],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.exit_fullscreen',
|
||||
defaultKey: ['Escape'],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_in',
|
||||
defaultKey: ['CommandOrControl', '='],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
variants: [['CommandOrControl', 'numadd']]
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_out',
|
||||
defaultKey: ['CommandOrControl', '-'],
|
||||
scope: 'main',
|
||||
category: 'app',
|
||||
variants: [['CommandOrControl', 'numsub']]
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.zoom_reset',
|
||||
defaultKey: ['CommandOrControl', '0'],
|
||||
scope: 'main',
|
||||
category: 'app'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.app.search_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'F'],
|
||||
scope: 'renderer',
|
||||
category: 'app'
|
||||
},
|
||||
// ==================== 聊天相关快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.chat.clear',
|
||||
defaultKey: ['CommandOrControl', 'L'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.chat.search_message',
|
||||
defaultKey: ['CommandOrControl', 'F'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.chat.toggle_new_context',
|
||||
defaultKey: ['CommandOrControl', 'K'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.chat.copy_last_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'C'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.chat.edit_last_user_message',
|
||||
defaultKey: ['CommandOrControl', 'Shift', 'E'],
|
||||
scope: 'renderer',
|
||||
category: 'chat'
|
||||
},
|
||||
// ==================== 话题管理快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.topic.new',
|
||||
defaultKey: ['CommandOrControl', 'N'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.topic.rename',
|
||||
defaultKey: ['CommandOrControl', 'T'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
{
|
||||
key: 'shortcut.topic.toggle_show_topics',
|
||||
defaultKey: ['CommandOrControl', ']'],
|
||||
scope: 'renderer',
|
||||
category: 'topic'
|
||||
},
|
||||
// ==================== 划词助手快捷键 ====================
|
||||
{
|
||||
key: 'shortcut.selection.toggle_enabled',
|
||||
defaultKey: [],
|
||||
scope: 'main',
|
||||
category: 'selection',
|
||||
persistOnBlur: true
|
||||
},
|
||||
{
|
||||
key: 'shortcut.selection.get_text',
|
||||
defaultKey: [],
|
||||
scope: 'main',
|
||||
category: 'selection',
|
||||
persistOnBlur: true
|
||||
}
|
||||
] as const
|
||||
|
||||
export const getShortcutsByCategory = () => {
|
||||
const groups: Record<ShortcutCategory, ShortcutDefinition[]> = {
|
||||
app: [],
|
||||
chat: [],
|
||||
topic: [],
|
||||
selection: []
|
||||
}
|
||||
|
||||
SHORTCUT_DEFINITIONS.forEach((definition) => {
|
||||
groups[definition.category].push(definition)
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
export const findShortcutDefinition = (key: string): ShortcutDefinition | undefined => {
|
||||
return SHORTCUT_DEFINITIONS.find((definition) => definition.key === key)
|
||||
}
|
||||
40
packages/shared/shortcuts/types.ts
Normal file
40
packages/shared/shortcuts/types.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/preference/preferenceTypes'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
export type ShortcutScope = 'main' | 'renderer' | 'both'
|
||||
|
||||
export type ShortcutCategory = 'app' | 'chat' | 'topic' | 'selection'
|
||||
|
||||
export type ShortcutPreferenceKey = Extract<PreferenceKeyType, `shortcut.${string}`>
|
||||
|
||||
export type GetPreferenceFn = <K extends PreferenceKeyType>(key: K) => PreferenceDefaultScopeType[K]
|
||||
|
||||
export type ShortcutEnabledPredicate = (getPreference: GetPreferenceFn) => boolean
|
||||
|
||||
export interface ShortcutDefinition {
|
||||
key: ShortcutPreferenceKey
|
||||
defaultKey: string[]
|
||||
scope: ShortcutScope
|
||||
category: ShortcutCategory
|
||||
persistOnBlur?: boolean
|
||||
variants?: string[][]
|
||||
enabledWhen?: ShortcutEnabledPredicate
|
||||
}
|
||||
|
||||
export interface ShortcutPreferenceValue {
|
||||
binding: string[]
|
||||
rawBinding: string[]
|
||||
hasCustomBinding: boolean
|
||||
enabled: boolean
|
||||
editable: boolean
|
||||
system: boolean
|
||||
}
|
||||
|
||||
export interface ShortcutRuntimeConfig extends ShortcutDefinition {
|
||||
binding: string[]
|
||||
enabled: boolean
|
||||
editable: boolean
|
||||
system: boolean
|
||||
}
|
||||
|
||||
export type ShortcutHandler = (window?: BrowserWindow) => void | Promise<void>
|
||||
137
packages/shared/shortcuts/utils.ts
Normal file
137
packages/shared/shortcuts/utils.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
|
||||
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
|
||||
|
||||
import type { ShortcutDefinition, ShortcutPreferenceValue } from './types'
|
||||
|
||||
const modifierKeys = ['CommandOrControl', 'Ctrl', 'Alt', 'Shift', 'Meta', 'Command']
|
||||
const specialSingleKeys = ['Escape', 'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12']
|
||||
|
||||
export const convertKeyToAccelerator = (key: string): string => {
|
||||
const keyMap: Record<string, string> = {
|
||||
Command: 'CommandOrControl',
|
||||
Cmd: 'CommandOrControl',
|
||||
Control: 'Ctrl',
|
||||
Meta: 'Meta',
|
||||
ArrowUp: 'Up',
|
||||
ArrowDown: 'Down',
|
||||
ArrowLeft: 'Left',
|
||||
ArrowRight: 'Right',
|
||||
AltGraph: 'AltGr',
|
||||
Slash: '/',
|
||||
Semicolon: ';',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
Quote: "'",
|
||||
Comma: ',',
|
||||
Minus: '-',
|
||||
Equal: '='
|
||||
}
|
||||
|
||||
return keyMap[key] || key
|
||||
}
|
||||
|
||||
export const convertAcceleratorToHotkey = (accelerator: string[]): string => {
|
||||
return accelerator
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'commandorcontrol':
|
||||
return 'mod'
|
||||
case 'command':
|
||||
case 'cmd':
|
||||
return 'meta'
|
||||
case 'control':
|
||||
case 'ctrl':
|
||||
return 'ctrl'
|
||||
case 'alt':
|
||||
return 'alt'
|
||||
case 'shift':
|
||||
return 'shift'
|
||||
case 'meta':
|
||||
return 'meta'
|
||||
default:
|
||||
return key.toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
|
||||
export const formatShortcutDisplay = (keys: string[], isMac: boolean): string => {
|
||||
return keys
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'command':
|
||||
case 'cmd':
|
||||
return isMac ? '⌘' : 'Win'
|
||||
case 'commandorcontrol':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'shift':
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
case 'meta':
|
||||
return isMac ? '⌘' : 'Win'
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
|
||||
}
|
||||
})
|
||||
.join(isMac ? '' : '+')
|
||||
}
|
||||
|
||||
export const isValidShortcut = (keys: string[]): boolean => {
|
||||
if (!keys.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasModifier = keys.some((key) => modifierKeys.includes(key))
|
||||
const isSpecialKey = keys.length === 1 && specialSingleKeys.includes(keys[0])
|
||||
|
||||
return hasModifier || isSpecialKey
|
||||
}
|
||||
|
||||
const ensureArray = (value: unknown): string[] => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((item): item is string => typeof item === 'string')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const ensureBoolean = (value: unknown, fallback: boolean): boolean => (typeof value === 'boolean' ? value : fallback)
|
||||
|
||||
export const getDefaultShortcutPreference = (definition: ShortcutDefinition): ShortcutPreferenceValue => {
|
||||
const fallback = DefaultPreferences.default[definition.key] as PreferenceShortcutType
|
||||
|
||||
const rawBinding = ensureArray(fallback?.key)
|
||||
const binding = rawBinding.length ? rawBinding : definition.defaultKey
|
||||
|
||||
return {
|
||||
binding,
|
||||
rawBinding: binding,
|
||||
hasCustomBinding: false,
|
||||
enabled: ensureBoolean(fallback?.enabled, true),
|
||||
editable: ensureBoolean(fallback?.editable, true),
|
||||
system: ensureBoolean(fallback?.system, false)
|
||||
}
|
||||
}
|
||||
|
||||
export const coerceShortcutPreference = (
|
||||
definition: ShortcutDefinition,
|
||||
value?: PreferenceShortcutType | null
|
||||
): ShortcutPreferenceValue => {
|
||||
const fallback = getDefaultShortcutPreference(definition)
|
||||
const hasCustomBinding = Array.isArray((value as PreferenceShortcutType | undefined)?.key)
|
||||
const rawBinding = hasCustomBinding ? ensureArray((value as PreferenceShortcutType).key) : fallback.binding
|
||||
const binding = rawBinding.length > 0 ? rawBinding : fallback.binding
|
||||
|
||||
return {
|
||||
binding,
|
||||
rawBinding,
|
||||
hasCustomBinding,
|
||||
enabled: ensureBoolean(value?.enabled, fallback.enabled),
|
||||
editable: ensureBoolean(value?.editable, fallback.editable),
|
||||
system: ensureBoolean(value?.system, fallback.system)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
setupAppImageDeepLink
|
||||
} from './services/ProtocolClient'
|
||||
import selectionService, { initSelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts } from './services/ShortcutService'
|
||||
import { shortcutService } from './services/ShortcutService'
|
||||
import { TrayService } from './services/TrayService'
|
||||
import { windowService } from './services/WindowService'
|
||||
import { dataRefactorMigrateService } from './data/migrate/dataRefactor/DataRefactorMigrateService'
|
||||
@@ -216,7 +216,7 @@ if (!app.requestSingleInstanceLock()) {
|
||||
}
|
||||
})
|
||||
|
||||
registerShortcuts(mainWindow)
|
||||
shortcutService.registerForWindow(mainWindow)
|
||||
registerIpc(mainWindow, app)
|
||||
|
||||
replaceDevtoolsFont(mainWindow)
|
||||
|
||||
@@ -21,7 +21,6 @@ import type {
|
||||
OcrProvider,
|
||||
PluginError,
|
||||
Provider,
|
||||
Shortcut,
|
||||
SupportedOcrFile
|
||||
} from '@types'
|
||||
import checkDiskSpace from 'check-disk-space'
|
||||
@@ -35,7 +34,6 @@ import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
import { codeToolsService } from './services/CodeToolsService'
|
||||
import { configManager } from './services/ConfigManager'
|
||||
import CopilotService from './services/CopilotService'
|
||||
import DxtService from './services/DxtService'
|
||||
import { ExportService } from './services/ExportService'
|
||||
@@ -56,7 +54,6 @@ import { pythonService } from './services/PythonService'
|
||||
import { FileServiceManager } from './services/remotefile/FileServiceManager'
|
||||
import { searchService } from './services/SearchService'
|
||||
import { SelectionService } from './services/SelectionService'
|
||||
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
|
||||
import {
|
||||
addEndMessage,
|
||||
addStreamMessage,
|
||||
@@ -581,16 +578,6 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
await shell.openPath(path)
|
||||
})
|
||||
|
||||
// shortcuts
|
||||
ipcMain.handle(IpcChannel.Shortcuts_Update, (_, shortcuts: Shortcut[]) => {
|
||||
configManager.setShortcuts(shortcuts)
|
||||
// Refresh shortcuts registration
|
||||
if (mainWindow) {
|
||||
unregisterAllShortcuts()
|
||||
registerShortcuts(mainWindow)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Create, KnowledgeService.create.bind(KnowledgeService))
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Reset, KnowledgeService.reset.bind(KnowledgeService))
|
||||
ipcMain.handle(IpcChannel.KnowledgeBase_Delete, KnowledgeService.delete.bind(KnowledgeService))
|
||||
|
||||
@@ -1,298 +1,307 @@
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { loggerService } from '@logger'
|
||||
import { handleZoomFactor } from '@main/utils/zoom'
|
||||
import type { Shortcut } from '@types'
|
||||
import type { PreferenceDefaultScopeType } from '@shared/data/preference/preferenceTypes'
|
||||
import { SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
|
||||
import type {
|
||||
ShortcutDefinition,
|
||||
ShortcutHandler,
|
||||
ShortcutPreferenceKey,
|
||||
ShortcutPreferenceValue,
|
||||
ShortcutRuntimeConfig
|
||||
} from '@shared/shortcuts/types'
|
||||
import { coerceShortcutPreference } from '@shared/shortcuts/utils'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { globalShortcut } from 'electron'
|
||||
|
||||
import { configManager } from './ConfigManager'
|
||||
import selectionService from './SelectionService'
|
||||
import { windowService } from './WindowService'
|
||||
|
||||
const logger = loggerService.withContext('ShortcutService')
|
||||
|
||||
let showAppAccelerator: string | null = null
|
||||
let showMiniWindowAccelerator: string | null = null
|
||||
let selectionAssistantToggleAccelerator: string | null = null
|
||||
let selectionAssistantSelectTextAccelerator: string | null = null
|
||||
const toAccelerator = (keys: string[]): string => keys.join('+')
|
||||
|
||||
//indicate if the shortcuts are registered on app boot time
|
||||
let isRegisterOnBoot = true
|
||||
const relevantDefinitions = SHORTCUT_DEFINITIONS.filter((definition) => definition.scope !== 'renderer')
|
||||
|
||||
// store the focus and blur handlers for each window to unregister them later
|
||||
const windowOnHandlers = new Map<BrowserWindow, { onFocusHandler: () => void; onBlurHandler: () => void }>()
|
||||
export class ShortcutService {
|
||||
private handlers = new Map<ShortcutPreferenceKey, ShortcutHandler>()
|
||||
private windowLifecycleHandlers = new Map<
|
||||
BrowserWindow,
|
||||
{ onFocus: () => void; onBlur: () => void; onClosed: () => void }
|
||||
>()
|
||||
private currentWindow: BrowserWindow | null = null
|
||||
private preferenceUnsubscribers: Array<() => void> = []
|
||||
|
||||
function getShortcutHandler(shortcut: Shortcut) {
|
||||
switch (shortcut.key) {
|
||||
case 'zoom_in':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0.1)
|
||||
case 'zoom_out':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], -0.1)
|
||||
case 'zoom_reset':
|
||||
return (window: BrowserWindow) => handleZoomFactor([window], 0, true)
|
||||
case 'show_app':
|
||||
return () => {
|
||||
windowService.toggleMainWindow()
|
||||
}
|
||||
case 'mini_window':
|
||||
return () => {
|
||||
windowService.toggleMiniWindow()
|
||||
}
|
||||
case 'selection_assistant_toggle':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
}
|
||||
}
|
||||
case 'selection_assistant_select_text':
|
||||
return () => {
|
||||
if (selectionService) {
|
||||
selectionService.processSelectTextByShortcut()
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
constructor() {
|
||||
this.registerBuiltInHandlers()
|
||||
this.subscribeToPreferenceChanges()
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortcutKey(shortcut: string[]): string {
|
||||
return shortcut.join('+')
|
||||
}
|
||||
|
||||
// convert the shortcut recorded by JS keyboard event key value to electron global shortcut format
|
||||
// see: https://www.electronjs.org/zh/docs/latest/api/accelerator
|
||||
const convertShortcutFormat = (shortcut: string | string[]): string => {
|
||||
const accelerator = (() => {
|
||||
if (Array.isArray(shortcut)) {
|
||||
return shortcut
|
||||
} else {
|
||||
return shortcut.split('+').map((key) => key.trim())
|
||||
public registerHandler(key: ShortcutPreferenceKey, handler: ShortcutHandler): void {
|
||||
if (this.handlers.has(key)) {
|
||||
logger.warn(`Handler for ${key} is being overwritten`)
|
||||
}
|
||||
})()
|
||||
|
||||
return accelerator
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// case 'Command':
|
||||
// return 'CommandOrControl'
|
||||
// case 'Control':
|
||||
// return 'Control'
|
||||
// case 'Ctrl':
|
||||
// return 'Control'
|
||||
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
// you can see all the modifier keys in the same
|
||||
case 'CommandOrControl':
|
||||
return 'CommandOrControl'
|
||||
case 'Ctrl':
|
||||
return 'Ctrl'
|
||||
case 'Alt':
|
||||
return 'Alt' // Use `Alt` instead of `Option`. The `Option` key only exists on macOS, whereas the `Alt` key is available on all platforms.
|
||||
case 'Meta':
|
||||
return 'Meta' // `Meta` key is mapped to the Windows key on Windows and Linux, `Cmd` on macOS.
|
||||
case 'Shift':
|
||||
return 'Shift'
|
||||
|
||||
// For backward compatibility with old data
|
||||
case 'Command':
|
||||
case 'Cmd':
|
||||
return 'CommandOrControl'
|
||||
case 'Control':
|
||||
return 'Ctrl'
|
||||
|
||||
case 'ArrowUp':
|
||||
return 'Up'
|
||||
case 'ArrowDown':
|
||||
return 'Down'
|
||||
case 'ArrowLeft':
|
||||
return 'Left'
|
||||
case 'ArrowRight':
|
||||
return 'Right'
|
||||
case 'AltGraph':
|
||||
return 'AltGr'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
return ';'
|
||||
case 'BracketLeft':
|
||||
return '['
|
||||
case 'BracketRight':
|
||||
return ']'
|
||||
case 'Backslash':
|
||||
return '\\'
|
||||
case 'Quote':
|
||||
return "'"
|
||||
case 'Comma':
|
||||
return ','
|
||||
case 'Minus':
|
||||
return '-'
|
||||
case 'Equal':
|
||||
return '='
|
||||
default:
|
||||
return key
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}
|
||||
|
||||
export function registerShortcuts(window: BrowserWindow) {
|
||||
if (isRegisterOnBoot) {
|
||||
window.once('ready-to-show', () => {
|
||||
if (preferenceService.get('app.tray.on_launch')) {
|
||||
registerOnlyUniversalShortcuts()
|
||||
}
|
||||
})
|
||||
isRegisterOnBoot = false
|
||||
this.handlers.set(key, handler)
|
||||
logger.debug(`Registered handler for ${key}`)
|
||||
}
|
||||
|
||||
//only for clearer code
|
||||
const registerOnlyUniversalShortcuts = () => {
|
||||
register(true)
|
||||
public registerForWindow(window: BrowserWindow): void {
|
||||
if (this.windowLifecycleHandlers.has(window)) {
|
||||
logger.warn(`Window ${window.id} already registered for shortcuts`)
|
||||
return
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
logger.debug(`Window ${window.id} focused - registering shortcuts`)
|
||||
this.currentWindow = window
|
||||
this.registerAllShortcuts(window)
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
logger.debug(`Window ${window.id} blurred - unregistering non-persistent shortcuts`)
|
||||
this.unregisterTransientShortcuts(window)
|
||||
}
|
||||
|
||||
const onClosed = () => {
|
||||
logger.debug(`Window ${window.id} closed - cleaning up shortcut registrations`)
|
||||
this.unregisterWindow(window)
|
||||
}
|
||||
|
||||
window.on('focus', onFocus)
|
||||
window.on('blur', onBlur)
|
||||
window.on('closed', onClosed)
|
||||
|
||||
this.windowLifecycleHandlers.set(window, { onFocus, onBlur, onClosed })
|
||||
this.currentWindow = window
|
||||
|
||||
if (window.isFocused()) {
|
||||
this.registerAllShortcuts(window)
|
||||
} else {
|
||||
this.unregisterTransientShortcuts(window)
|
||||
}
|
||||
|
||||
logger.info(`ShortcutService attached to window ${window.id}`)
|
||||
}
|
||||
|
||||
//onlyUniversalShortcuts is used to register shortcuts that are not window specific, like show_app & mini_window
|
||||
//onlyUniversalShortcuts is needed when we launch to tray
|
||||
const register = (onlyUniversalShortcuts: boolean = false) => {
|
||||
if (window.isDestroyed()) return
|
||||
public unregisterWindow(window: BrowserWindow): void {
|
||||
const lifecycle = this.windowLifecycleHandlers.get(window)
|
||||
if (!lifecycle) {
|
||||
return
|
||||
}
|
||||
|
||||
const shortcuts = configManager.getShortcuts()
|
||||
if (!shortcuts) return
|
||||
window.off('focus', lifecycle.onFocus)
|
||||
window.off('blur', lifecycle.onBlur)
|
||||
window.off('closed', lifecycle.onClosed)
|
||||
|
||||
shortcuts.forEach((shortcut) => {
|
||||
try {
|
||||
if (shortcut.shortcut.length === 0) {
|
||||
this.windowLifecycleHandlers.delete(window)
|
||||
|
||||
if (this.currentWindow === window) {
|
||||
this.currentWindow = null
|
||||
globalShortcut.unregisterAll()
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
this.windowLifecycleHandlers.forEach((_handlers, window) => this.unregisterWindow(window))
|
||||
this.windowLifecycleHandlers.clear()
|
||||
this.handlers.clear()
|
||||
this.currentWindow = null
|
||||
|
||||
this.preferenceUnsubscribers.forEach((unsubscribe) => unsubscribe())
|
||||
this.preferenceUnsubscribers = []
|
||||
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
logger.info('ShortcutService cleaned up')
|
||||
}
|
||||
|
||||
private registerBuiltInHandlers(): void {
|
||||
this.registerHandler('shortcut.app.show_main_window', () => {
|
||||
windowService.toggleMainWindow()
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.app.show_settings', () => {
|
||||
let targetWindow = windowService.getMainWindow()
|
||||
|
||||
if (
|
||||
!targetWindow ||
|
||||
targetWindow.isDestroyed() ||
|
||||
targetWindow.isMinimized() ||
|
||||
!targetWindow.isVisible() ||
|
||||
!targetWindow.isFocused()
|
||||
) {
|
||||
windowService.showMainWindow()
|
||||
targetWindow = windowService.getMainWindow()
|
||||
}
|
||||
|
||||
if (!targetWindow || targetWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
void targetWindow.webContents
|
||||
.executeJavaScript(`typeof window.navigate === 'function' && window.navigate('/settings/provider')`, true)
|
||||
.catch((error) => {
|
||||
logger.warn('Failed to navigate to settings from shortcut:', error as Error)
|
||||
})
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.app.show_mini_window', () => {
|
||||
windowService.toggleMiniWindow()
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.app.zoom_in', (window) => {
|
||||
if (window) {
|
||||
handleZoomFactor([window], 0.1)
|
||||
}
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.app.zoom_out', (window) => {
|
||||
if (window) {
|
||||
handleZoomFactor([window], -0.1)
|
||||
}
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.app.zoom_reset', (window) => {
|
||||
if (window) {
|
||||
handleZoomFactor([window], 0, true)
|
||||
}
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.selection.toggle_enabled', () => {
|
||||
if (selectionService) {
|
||||
selectionService.toggleEnabled()
|
||||
}
|
||||
})
|
||||
|
||||
this.registerHandler('shortcut.selection.get_text', () => {
|
||||
if (selectionService) {
|
||||
selectionService.processSelectTextByShortcut()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private subscribeToPreferenceChanges(): void {
|
||||
this.preferenceUnsubscribers = relevantDefinitions.map((definition) =>
|
||||
preferenceService.subscribeChange(definition.key, () => {
|
||||
logger.debug(`Shortcut preference changed: ${definition.key}`)
|
||||
this.reregisterShortcuts()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private registerAllShortcuts(window: BrowserWindow): void {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
relevantDefinitions.forEach((definition) => {
|
||||
const runtimeConfig = this.getRuntimeConfig(definition)
|
||||
if (!runtimeConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
|
||||
logger.debug(`Skipping ${definition.key} - enabledWhen condition not met`)
|
||||
return
|
||||
}
|
||||
|
||||
const handler = this.handlers.get(definition.key)
|
||||
if (!handler) {
|
||||
logger.warn(`No handler registered for ${definition.key}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
|
||||
|
||||
if (definition.variants) {
|
||||
definition.variants.forEach((variant) => {
|
||||
this.registerSingleShortcut(variant, handler, window)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private unregisterTransientShortcuts(window: BrowserWindow): void {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
relevantDefinitions
|
||||
.filter((definition) => definition.persistOnBlur)
|
||||
.forEach((definition) => {
|
||||
const runtimeConfig = this.getRuntimeConfig(definition)
|
||||
if (!runtimeConfig.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
//if not enabled, exit early from the process.
|
||||
if (!shortcut.enabled) {
|
||||
if (definition.enabledWhen && !definition.enabledWhen(this.getPreferenceValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
// only register universal shortcuts when needed
|
||||
if (
|
||||
onlyUniversalShortcuts &&
|
||||
!['show_app', 'mini_window', 'selection_assistant_toggle', 'selection_assistant_select_text'].includes(
|
||||
shortcut.key
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const handler = getShortcutHandler(shortcut)
|
||||
const handler = this.handlers.get(definition.key)
|
||||
if (!handler) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (shortcut.key) {
|
||||
case 'show_app':
|
||||
showAppAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
this.registerSingleShortcut(runtimeConfig.binding, handler, window)
|
||||
|
||||
case 'mini_window':
|
||||
//available only when QuickAssistant enabled
|
||||
if (!preferenceService.get('feature.quick_assistant.enabled')) {
|
||||
return
|
||||
}
|
||||
showMiniWindowAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_toggle':
|
||||
selectionAssistantToggleAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
case 'selection_assistant_select_text':
|
||||
selectionAssistantSelectTextAccelerator = formatShortcutKey(shortcut.shortcut)
|
||||
break
|
||||
|
||||
//the following ZOOMs will register shortcuts separately, so will return
|
||||
case 'zoom_in':
|
||||
globalShortcut.register('CommandOrControl+=', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numadd', () => handler(window))
|
||||
return
|
||||
|
||||
case 'zoom_out':
|
||||
globalShortcut.register('CommandOrControl+-', () => handler(window))
|
||||
globalShortcut.register('CommandOrControl+numsub', () => handler(window))
|
||||
return
|
||||
|
||||
case 'zoom_reset':
|
||||
globalShortcut.register('CommandOrControl+0', () => handler(window))
|
||||
return
|
||||
if (definition.variants) {
|
||||
definition.variants.forEach((variant) => {
|
||||
this.registerSingleShortcut(variant, handler, window)
|
||||
})
|
||||
}
|
||||
|
||||
const accelerator = convertShortcutFormat(shortcut.shortcut)
|
||||
|
||||
globalShortcut.register(accelerator, () => handler(window))
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to register shortcut ${shortcut.key}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const unregister = () => {
|
||||
if (window.isDestroyed()) return
|
||||
private registerSingleShortcut(keys: string[], handler: ShortcutHandler, window: BrowserWindow): void {
|
||||
if (!keys.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const accelerator = toAccelerator(keys)
|
||||
|
||||
try {
|
||||
globalShortcut.unregisterAll()
|
||||
|
||||
if (showAppAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showAppAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (showMiniWindowAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'mini_window' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(showMiniWindowAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantToggleAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_toggle' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantToggleAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
|
||||
if (selectionAssistantSelectTextAccelerator) {
|
||||
const handler = getShortcutHandler({ key: 'selection_assistant_select_text' } as Shortcut)
|
||||
const accelerator = convertShortcutFormat(selectionAssistantSelectTextAccelerator)
|
||||
handler && globalShortcut.register(accelerator, () => handler(window))
|
||||
}
|
||||
globalShortcut.register(accelerator, () => {
|
||||
logger.debug(`Shortcut triggered: ${accelerator}`)
|
||||
const targetWindow = window?.isDestroyed?.() ? undefined : window
|
||||
handler(targetWindow)
|
||||
})
|
||||
logger.verbose(`Registered shortcut: ${accelerator}`)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to unregister shortcuts')
|
||||
logger.error(`Failed to register shortcut ${accelerator}:`, error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// only register the event handlers once
|
||||
if (undefined === windowOnHandlers.get(window)) {
|
||||
// pass register() directly to listener, the func will receive Event as argument, it's not expected
|
||||
const registerHandler = () => {
|
||||
register()
|
||||
private getRuntimeConfig(definition: ShortcutDefinition): ShortcutRuntimeConfig {
|
||||
const preference = this.getPreference(definition)
|
||||
return {
|
||||
...definition,
|
||||
binding: preference.binding,
|
||||
enabled: preference.enabled,
|
||||
editable: preference.editable,
|
||||
system: preference.system
|
||||
}
|
||||
window.on('focus', registerHandler)
|
||||
window.on('blur', unregister)
|
||||
windowOnHandlers.set(window, { onFocusHandler: registerHandler, onBlurHandler: unregister })
|
||||
}
|
||||
|
||||
if (!window.isDestroyed() && window.isFocused()) {
|
||||
register()
|
||||
private getPreference(definition: ShortcutDefinition): ShortcutPreferenceValue {
|
||||
const rawPreference = preferenceService.get(definition.key)
|
||||
return coerceShortcutPreference(definition, rawPreference as any)
|
||||
}
|
||||
|
||||
private getPreferenceValue = <K extends ShortcutPreferenceKey | keyof PreferenceDefaultScopeType>(
|
||||
key: K
|
||||
): PreferenceDefaultScopeType[K] => {
|
||||
return preferenceService.get(key)
|
||||
}
|
||||
|
||||
private reregisterShortcuts(): void {
|
||||
if (!this.currentWindow || this.currentWindow.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.currentWindow.isFocused()) {
|
||||
this.registerAllShortcuts(this.currentWindow)
|
||||
return
|
||||
}
|
||||
|
||||
this.unregisterTransientShortcuts(this.currentWindow)
|
||||
}
|
||||
}
|
||||
|
||||
export function unregisterAllShortcuts() {
|
||||
try {
|
||||
showAppAccelerator = null
|
||||
showMiniWindowAccelerator = null
|
||||
selectionAssistantToggleAccelerator = null
|
||||
selectionAssistantSelectTextAccelerator = null
|
||||
windowOnHandlers.forEach((handlers, window) => {
|
||||
window.off('focus', handlers.onFocusHandler)
|
||||
window.off('blur', handlers.onBlurHandler)
|
||||
})
|
||||
windowOnHandlers.clear()
|
||||
globalShortcut.unregisterAll()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to unregister all shortcuts')
|
||||
}
|
||||
}
|
||||
export const shortcutService = new ShortcutService()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box } from '@cherrystudio/ui'
|
||||
import { getToastUtilities } from '@cherrystudio/ui'
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { Modal } from 'antd'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -35,8 +35,9 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
elementsRef.current = elements
|
||||
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const { shortcuts } = useShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.key === 'exit_fullscreen')?.enabled
|
||||
const shortcuts = useAllShortcuts()
|
||||
const enableQuitFullScreen = shortcuts.find((item) => item.definition.key === 'shortcut.app.exit_fullscreen')
|
||||
?.preference.enabled
|
||||
|
||||
useAppInit()
|
||||
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { useEffect } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
const NavigationHandler: React.FC = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const showSettingsShortcutEnabled = useAppSelector(
|
||||
(state) => state.shortcuts.shortcuts.find((s) => s.key === 'show_settings')?.enabled
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'meta+, ! ctrl+,',
|
||||
function () {
|
||||
useShortcut(
|
||||
'shortcut.app.show_settings',
|
||||
() => {
|
||||
if (location.pathname.startsWith('/settings')) {
|
||||
return
|
||||
}
|
||||
navigate('/settings/provider')
|
||||
},
|
||||
{
|
||||
splitKey: '!',
|
||||
enableOnContentEditable: true,
|
||||
enableOnFormTags: true,
|
||||
enabled: showSettingsShortcutEnabled
|
||||
enableOnContentEditable: true
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useCallback } from 'react'
|
||||
import { useMultiplePreferences, usePreference } from '@data/hooks/usePreference'
|
||||
import { isMac } from '@renderer/config/constant'
|
||||
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
|
||||
import { findShortcutDefinition, SHORTCUT_DEFINITIONS } from '@shared/shortcuts/definitions'
|
||||
import type { ShortcutDefinition, ShortcutPreferenceKey, ShortcutPreferenceValue } from '@shared/shortcuts/types'
|
||||
import {
|
||||
coerceShortcutPreference,
|
||||
convertAcceleratorToHotkey,
|
||||
formatShortcutDisplay,
|
||||
getDefaultShortcutPreference
|
||||
} from '@shared/shortcuts/utils'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
|
||||
interface UseShortcutOptions {
|
||||
@@ -9,85 +17,175 @@ interface UseShortcutOptions {
|
||||
enableOnFormTags?: boolean
|
||||
enabled?: boolean
|
||||
description?: string
|
||||
enableOnContentEditable?: boolean
|
||||
}
|
||||
|
||||
const defaultOptions: UseShortcutOptions = {
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true,
|
||||
enabled: true
|
||||
enabled: true,
|
||||
enableOnContentEditable: false
|
||||
}
|
||||
|
||||
const resolvePreferenceValue = (
|
||||
definition: ShortcutDefinition | undefined,
|
||||
preference: PreferenceShortcutType | Record<string, unknown> | undefined
|
||||
): ShortcutPreferenceValue | null => {
|
||||
if (!definition) {
|
||||
return null
|
||||
}
|
||||
return coerceShortcutPreference(definition, preference as PreferenceShortcutType | undefined)
|
||||
}
|
||||
|
||||
export const useShortcut = (
|
||||
shortcutKey: string,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
shortcutKey: ShortcutPreferenceKey,
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
options: UseShortcutOptions = defaultOptions
|
||||
) => {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
|
||||
const [preference] = usePreference(shortcutKey)
|
||||
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
|
||||
|
||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
||||
return shortcut
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'command':
|
||||
return 'meta'
|
||||
case 'commandorcontrol':
|
||||
return isMac ? 'meta' : 'ctrl'
|
||||
default:
|
||||
return key.toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}, [])
|
||||
const hotkey = useMemo(() => {
|
||||
if (!definition || !preferenceState) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === shortcutKey)
|
||||
if (definition.scope === 'main') {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
if (!preferenceState.enabled) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
const effectiveBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
|
||||
|
||||
if (!effectiveBinding.length) {
|
||||
return 'none'
|
||||
}
|
||||
|
||||
return convertAcceleratorToHotkey(effectiveBinding)
|
||||
}, [definition, preferenceState])
|
||||
|
||||
useHotkeys(
|
||||
shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : 'none',
|
||||
(e) => {
|
||||
hotkey,
|
||||
(event) => {
|
||||
if (options.preventDefault) {
|
||||
e.preventDefault()
|
||||
event.preventDefault()
|
||||
}
|
||||
if (options.enabled !== false) {
|
||||
callback(e)
|
||||
callback(event)
|
||||
}
|
||||
},
|
||||
{
|
||||
enableOnFormTags: options.enableOnFormTags,
|
||||
description: options.description || shortcutConfig?.key,
|
||||
enabled: !!shortcutConfig?.enabled
|
||||
}
|
||||
description: options.description ?? shortcutKey,
|
||||
enabled: hotkey !== 'none',
|
||||
enableOnContentEditable: options.enableOnContentEditable
|
||||
},
|
||||
[hotkey, callback, options]
|
||||
)
|
||||
}
|
||||
|
||||
export function useShortcuts() {
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
return { shortcuts: orderBy(shortcuts, 'system', 'desc') }
|
||||
export const useShortcutDisplay = (shortcutKey: ShortcutPreferenceKey): string => {
|
||||
const definition = useMemo(() => findShortcutDefinition(shortcutKey), [shortcutKey])
|
||||
const [preference] = usePreference(shortcutKey)
|
||||
const preferenceState = useMemo(() => resolvePreferenceValue(definition, preference), [definition, preference])
|
||||
|
||||
if (!definition || !preferenceState || !preferenceState.enabled) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const displayBinding = preferenceState.binding.length > 0 ? preferenceState.binding : definition.defaultKey
|
||||
|
||||
if (!displayBinding.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return formatShortcutDisplay(displayBinding, isMac)
|
||||
}
|
||||
|
||||
export function useShortcutDisplay(key: string) {
|
||||
const formatShortcut = useCallback((shortcut: string[]) => {
|
||||
return shortcut
|
||||
.map((key) => {
|
||||
switch (key.toLowerCase()) {
|
||||
case 'control':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'command':
|
||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'shift':
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
case 'commandorcontrol':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()
|
||||
}
|
||||
})
|
||||
.join('+')
|
||||
}, [])
|
||||
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === key)
|
||||
return shortcutConfig?.enabled ? formatShortcut(shortcutConfig.shortcut) : ''
|
||||
export interface ShortcutListItem {
|
||||
definition: ShortcutDefinition
|
||||
preference: ShortcutPreferenceValue
|
||||
defaultPreference: ShortcutPreferenceValue
|
||||
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useAllShortcuts = (): ShortcutListItem[] => {
|
||||
const keyMap = useMemo(
|
||||
() =>
|
||||
SHORTCUT_DEFINITIONS.reduce<Record<string, ShortcutPreferenceKey>>((acc, definition) => {
|
||||
acc[definition.key] = definition.key
|
||||
return acc
|
||||
}, {}),
|
||||
[]
|
||||
)
|
||||
|
||||
const [values, setValues] = useMultiplePreferences(keyMap)
|
||||
|
||||
const buildNextPreference = useCallback(
|
||||
(
|
||||
state: ShortcutPreferenceValue,
|
||||
currentValue: PreferenceShortcutType | undefined,
|
||||
patch: Partial<PreferenceShortcutType>
|
||||
): PreferenceShortcutType => {
|
||||
const current = (currentValue ?? {}) as PreferenceShortcutType
|
||||
|
||||
const nextKey = Array.isArray(patch.key) ? patch.key : Array.isArray(current.key) ? current.key : state.rawBinding
|
||||
|
||||
const nextEnabled =
|
||||
typeof patch.enabled === 'boolean'
|
||||
? patch.enabled
|
||||
: typeof current.enabled === 'boolean'
|
||||
? current.enabled
|
||||
: state.enabled
|
||||
|
||||
const nextEditable =
|
||||
typeof patch.editable === 'boolean'
|
||||
? patch.editable
|
||||
: typeof current.editable === 'boolean'
|
||||
? current.editable
|
||||
: state.editable
|
||||
|
||||
const nextSystem =
|
||||
typeof patch.system === 'boolean'
|
||||
? patch.system
|
||||
: typeof current.system === 'boolean'
|
||||
? current.system
|
||||
: state.system
|
||||
|
||||
return {
|
||||
key: nextKey,
|
||||
enabled: nextEnabled,
|
||||
editable: nextEditable,
|
||||
system: nextSystem
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
SHORTCUT_DEFINITIONS.map((definition) => {
|
||||
const rawValue = values[definition.key] as PreferenceShortcutType | undefined
|
||||
const preference = coerceShortcutPreference(definition, rawValue)
|
||||
const defaultPreference = getDefaultShortcutPreference(definition)
|
||||
|
||||
const updatePreference = async (patch: Partial<PreferenceShortcutType>) => {
|
||||
const currentValue = values[definition.key] as PreferenceShortcutType | undefined
|
||||
const nextValue = buildNextPreference(preference, currentValue, patch)
|
||||
await setValues({ [definition.key]: nextValue } as Partial<Record<string, PreferenceShortcutType>>)
|
||||
}
|
||||
|
||||
return {
|
||||
definition,
|
||||
preference,
|
||||
defaultPreference,
|
||||
updatePreference
|
||||
}
|
||||
}),
|
||||
[buildNextPreference, setValues, values]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ const Chat: FC<Props> = (props) => {
|
||||
contentSearchRef.current?.disable()
|
||||
})
|
||||
|
||||
useShortcut('search_message_in_chat', () => {
|
||||
useShortcut('shortcut.chat.search_message', () => {
|
||||
try {
|
||||
const selectedText = window.getSelection()?.toString().trim()
|
||||
contentSearchRef.current?.enable(selectedText)
|
||||
@@ -79,7 +79,7 @@ const Chat: FC<Props> = (props) => {
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('rename_topic', async () => {
|
||||
useShortcut('shortcut.topic.rename', async () => {
|
||||
const topic = props.activeTopic
|
||||
if (!topic) return
|
||||
|
||||
@@ -98,7 +98,7 @@ const Chat: FC<Props> = (props) => {
|
||||
})
|
||||
|
||||
useShortcut(
|
||||
'new_topic',
|
||||
'shortcut.topic.new',
|
||||
() => {
|
||||
if (activeTopicOrSession !== 'session' || !activeAgentId) {
|
||||
return
|
||||
|
||||
@@ -37,9 +37,9 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
const { isTopNavbar } = useNavbarPosition()
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
|
||||
|
||||
useShortcut('toggle_show_topics', () => {
|
||||
useShortcut('shortcut.topic.toggle_show_topics', () => {
|
||||
if (topicPosition === 'right') {
|
||||
toggleShowTopics()
|
||||
} else {
|
||||
@@ -47,7 +47,7 @@ const HeaderNavbar: FC<Props> = ({ activeAssistant, setActiveAssistant, activeTo
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
SearchPopup.show()
|
||||
})
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ const AgentSessionInputbar: FC<Props> = ({ agentId, sessionId }) => {
|
||||
const { session } = useSession(agentId, sessionId)
|
||||
const { apiServer } = useSettings()
|
||||
const { createDefaultSession, creatingSession } = useCreateDefaultSession(agentId)
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
|
||||
|
||||
const { sendMessageShortcut, fontSize, enableSpellCheck } = useSettings()
|
||||
const textareaRef = useRef<TextAreaRef>(null)
|
||||
|
||||
@@ -723,13 +723,13 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
}
|
||||
}, [onPaste])
|
||||
|
||||
useShortcut('new_topic', () => {
|
||||
useShortcut('shortcut.topic.new', () => {
|
||||
addNewTopic()
|
||||
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
|
||||
focusTextarea()
|
||||
})
|
||||
|
||||
useShortcut('clear_topic', clearTopic)
|
||||
useShortcut('shortcut.chat.clear', clearTopic)
|
||||
|
||||
useEffect(() => {
|
||||
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })
|
||||
|
||||
@@ -194,8 +194,8 @@ const InputbarTools = ({
|
||||
updateAssistant({ enableGenerateImage: !assistant.enableGenerateImage })
|
||||
}, [assistant.enableGenerateImage, updateAssistant])
|
||||
|
||||
const newTopicShortcut = useShortcutDisplay('new_topic')
|
||||
const clearTopicShortcut = useShortcutDisplay('clear_topic')
|
||||
const newTopicShortcut = useShortcutDisplay('shortcut.topic.new')
|
||||
const clearTopicShortcut = useShortcutDisplay('shortcut.chat.clear')
|
||||
|
||||
const toggleToolVisibility = useCallback(
|
||||
(toolKey: InputBarToolType, isVisible: boolean | undefined) => {
|
||||
|
||||
@@ -9,10 +9,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const NewContextButton: FC<Props> = ({ onNewContext }) => {
|
||||
const newContextShortcut = useShortcutDisplay('toggle_new_context')
|
||||
const newContextShortcut = useShortcutDisplay('shortcut.chat.toggle_new_context')
|
||||
const { t } = useTranslation()
|
||||
|
||||
useShortcut('toggle_new_context', onNewContext)
|
||||
useShortcut('shortcut.chat.toggle_new_context', onNewContext)
|
||||
|
||||
return (
|
||||
<Tooltip content={t('chat.input.new.context', { Command: newContextShortcut })} closeDelay={0}>
|
||||
|
||||
@@ -268,7 +268,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
)
|
||||
}, [displayMessages.length, hasMore, isLoadingMore, messages, setTimeoutTimer])
|
||||
|
||||
useShortcut('copy_last_message', () => {
|
||||
useShortcut('shortcut.chat.copy_last_message', () => {
|
||||
const lastMessage = last(messages)
|
||||
if (lastMessage) {
|
||||
navigator.clipboard.writeText(getMainTextContent(lastMessage))
|
||||
@@ -276,7 +276,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic, o
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('edit_last_user_message', () => {
|
||||
useShortcut('shortcut.chat.edit_last_user_message', () => {
|
||||
const lastUserMessage = messagesRef.current.findLast((m) => m.role === 'user' && m.type !== 'clear')
|
||||
if (lastUserMessage) {
|
||||
EventEmitter.emit(EVENT_NAMES.EDIT_MESSAGE, lastUserMessage.id)
|
||||
|
||||
@@ -38,9 +38,9 @@ const HeaderNavbar: FC<Props> = ({
|
||||
const { showAssistants, toggleShowAssistants } = useShowAssistants()
|
||||
const { showTopics, toggleShowTopics } = useShowTopics()
|
||||
|
||||
useShortcut('toggle_show_assistants', toggleShowAssistants)
|
||||
useShortcut('shortcut.app.toggle_show_assistants', toggleShowAssistants)
|
||||
|
||||
useShortcut('toggle_show_topics', () => {
|
||||
useShortcut('shortcut.topic.toggle_show_topics', () => {
|
||||
if (topicPosition === 'right') {
|
||||
toggleShowTopics()
|
||||
} else {
|
||||
@@ -48,7 +48,7 @@ const HeaderNavbar: FC<Props> = ({
|
||||
}
|
||||
})
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
SearchPopup.show()
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ const KnowledgePage: FC = () => {
|
||||
[deleteKnowledgeBase, handleEditKnowledgeBase, renameKnowledgeBase, t]
|
||||
)
|
||||
|
||||
useShortcut('search_message', () => {
|
||||
useShortcut('shortcut.app.search_message', () => {
|
||||
if (selectedBase) {
|
||||
KnowledgeSearchPopup.show({ base: selectedBase }).then()
|
||||
}
|
||||
|
||||
@@ -1,173 +1,150 @@
|
||||
import { ClearOutlined, UndoOutlined } from '@ant-design/icons'
|
||||
import { Button, RowFlex, Switch, Tooltip } from '@cherrystudio/ui'
|
||||
import { preferenceService } from '@data/PreferenceService'
|
||||
import { isMac, isWin } from '@renderer/config/constant'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { useAllShortcuts } from '@renderer/hooks/useShortcuts'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { getShortcutLabel } from '@renderer/i18n/label'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import { initialState, resetShortcuts, toggleShortcut, updateShortcut } from '@renderer/store/shortcuts'
|
||||
import type { Shortcut } from '@renderer/types'
|
||||
import type { PreferenceShortcutType } from '@shared/data/preference/preferenceTypes'
|
||||
import type { ShortcutPreferenceKey } from '@shared/shortcuts/types'
|
||||
import { convertKeyToAccelerator, formatShortcutDisplay, isValidShortcut } from '@shared/shortcuts/utils'
|
||||
import type { InputRef } from 'antd'
|
||||
import { Input, Table as AntTable } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import type { FC } from 'react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import type { FC, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
|
||||
|
||||
const labelKeyMap: Record<string, string> = {
|
||||
'shortcut.app.show_main_window': 'show_app',
|
||||
'shortcut.app.show_mini_window': 'mini_window',
|
||||
'shortcut.app.show_settings': 'show_settings',
|
||||
'shortcut.app.toggle_show_assistants': 'toggle_show_assistants',
|
||||
'shortcut.app.exit_fullscreen': 'exit_fullscreen',
|
||||
'shortcut.app.zoom_in': 'zoom_in',
|
||||
'shortcut.app.zoom_out': 'zoom_out',
|
||||
'shortcut.app.zoom_reset': 'zoom_reset',
|
||||
'shortcut.app.search_message': 'search_message',
|
||||
'shortcut.chat.clear': 'clear_topic',
|
||||
'shortcut.chat.search_message': 'search_message_in_chat',
|
||||
'shortcut.chat.toggle_new_context': 'toggle_new_context',
|
||||
'shortcut.chat.copy_last_message': 'copy_last_message',
|
||||
'shortcut.chat.edit_last_user_message': 'edit_last_user_message',
|
||||
'shortcut.topic.new': 'new_topic',
|
||||
'shortcut.topic.rename': 'rename_topic',
|
||||
'shortcut.topic.toggle_show_topics': 'toggle_show_topics',
|
||||
'shortcut.selection.toggle_enabled': 'selection_assistant_toggle',
|
||||
'shortcut.selection.get_text': 'selection_assistant_select_text'
|
||||
}
|
||||
|
||||
type ShortcutRecord = {
|
||||
id: string
|
||||
label: string
|
||||
key: ShortcutPreferenceKey
|
||||
enabled: boolean
|
||||
editable: boolean
|
||||
displayKeys: string[]
|
||||
rawKeys: string[]
|
||||
hasCustomBinding: boolean
|
||||
system: boolean
|
||||
updatePreference: (patch: Partial<PreferenceShortcutType>) => Promise<void>
|
||||
defaultPreference: {
|
||||
binding: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const ShortcutSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { shortcuts: originalShortcuts } = useShortcuts()
|
||||
const shortcuts = useAllShortcuts()
|
||||
const inputRefs = useRef<Record<string, InputRef>>({})
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
//if shortcut is not available on all the platforms, block the shortcut here
|
||||
let shortcuts = originalShortcuts
|
||||
if (!isWin && !isMac) {
|
||||
//Selection Assistant only available on Windows now
|
||||
const excludedShortcuts = ['selection_assistant_toggle', 'selection_assistant_select_text']
|
||||
shortcuts = shortcuts.filter((s) => !excludedShortcuts.includes(s.key))
|
||||
const displayedShortcuts = useMemo<ShortcutRecord[]>(() => {
|
||||
const filtered = !isWin && !isMac ? shortcuts.filter((item) => item.definition.category !== 'selection') : shortcuts
|
||||
|
||||
return filtered.map((item) => {
|
||||
const labelKey = labelKeyMap[item.definition.key] ?? item.definition.key
|
||||
const label = getShortcutLabel(labelKey)
|
||||
|
||||
const displayKeys = item.preference.hasCustomBinding
|
||||
? item.preference.rawBinding
|
||||
: item.preference.binding.length > 0
|
||||
? item.preference.binding
|
||||
: item.definition.defaultKey
|
||||
|
||||
return {
|
||||
id: item.definition.key,
|
||||
label,
|
||||
key: item.definition.key,
|
||||
enabled: item.preference.enabled,
|
||||
editable: item.preference.editable,
|
||||
displayKeys,
|
||||
rawKeys: item.preference.rawBinding,
|
||||
hasCustomBinding: item.preference.hasCustomBinding,
|
||||
system: item.preference.system,
|
||||
updatePreference: item.updatePreference,
|
||||
defaultPreference: {
|
||||
binding: item.defaultPreference.binding,
|
||||
enabled: item.defaultPreference.enabled
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [shortcuts])
|
||||
|
||||
const handleClear = (record: ShortcutRecord) => {
|
||||
void record.updatePreference({ key: [] })
|
||||
}
|
||||
|
||||
const handleClear = (record: Shortcut) => {
|
||||
dispatch(
|
||||
updateShortcut({
|
||||
...record,
|
||||
shortcut: []
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddShortcut = (record: Shortcut) => {
|
||||
setEditingKey(record.key)
|
||||
const handleAddShortcut = (record: ShortcutRecord) => {
|
||||
setEditingKey(record.id)
|
||||
setTimeoutTimer(
|
||||
'handleAddShortcut',
|
||||
`focus-${record.id}`,
|
||||
() => {
|
||||
inputRefs.current[record.key]?.focus()
|
||||
inputRefs.current[record.id]?.focus()
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const isShortcutModified = (record: Shortcut) => {
|
||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
||||
return defaultShortcut?.shortcut.join('+') !== record.shortcut.join('+')
|
||||
const isShortcutModified = (record: ShortcutRecord) => {
|
||||
const bindingChanged = record.hasCustomBinding
|
||||
? record.rawKeys.length !== record.defaultPreference.binding.length ||
|
||||
record.rawKeys.some((key, index) => key !== record.defaultPreference.binding[index])
|
||||
: false
|
||||
|
||||
const enabledChanged = record.enabled !== record.defaultPreference.enabled
|
||||
|
||||
return bindingChanged || enabledChanged
|
||||
}
|
||||
|
||||
const handleResetShortcut = (record: Shortcut) => {
|
||||
const defaultShortcut = initialState.shortcuts.find((s) => s.key === record.key)
|
||||
if (defaultShortcut) {
|
||||
dispatch(
|
||||
updateShortcut({
|
||||
...record,
|
||||
shortcut: defaultShortcut.shortcut
|
||||
})
|
||||
)
|
||||
}
|
||||
const handleResetShortcut = (record: ShortcutRecord) => {
|
||||
void record.updatePreference({
|
||||
key: record.defaultPreference.binding,
|
||||
enabled: record.defaultPreference.enabled
|
||||
})
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
const isValidShortcut = (keys: string[]): boolean => {
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// const hasModifier = keys.some((key) => ['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
// const hasNonModifier = keys.some((key) => !['Control', 'Ctrl', 'Command', 'Alt', 'Shift'].includes(key))
|
||||
const isDuplicateShortcut = (keys: string[], currentKey: ShortcutPreferenceKey) => {
|
||||
const normalized = keys.map((key) => key.toLowerCase()).join('+')
|
||||
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
const hasModifier = keys.some((key) => ['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
|
||||
const hasNonModifier = keys.some((key) => !['CommandOrControl', 'Ctrl', 'Alt', 'Meta', 'Shift'].includes(key))
|
||||
|
||||
const hasFnKey = keys.some((key) => /^F\d+$/.test(key))
|
||||
|
||||
return (hasModifier && hasNonModifier && keys.length >= 2) || hasFnKey
|
||||
return displayedShortcuts.some((record) => {
|
||||
if (record.key === currentKey) return false
|
||||
const binding = record.displayKeys
|
||||
if (!binding.length) return false
|
||||
return binding.map((key) => key.toLowerCase()).join('+') === normalized
|
||||
})
|
||||
}
|
||||
|
||||
const isDuplicateShortcut = (newShortcut: string[], currentKey: string): boolean => {
|
||||
return shortcuts.some(
|
||||
(s) => s.key !== currentKey && s.shortcut.length > 0 && s.shortcut.join('+') === newShortcut.join('+')
|
||||
)
|
||||
}
|
||||
|
||||
// how the shortcut is displayed in the UI
|
||||
const formatShortcut = (shortcut: string[]): string => {
|
||||
return shortcut
|
||||
.map((key) => {
|
||||
switch (key) {
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// case 'Control':
|
||||
// return isMac ? '⌃' : 'Ctrl'
|
||||
// case 'Ctrl':
|
||||
// return isMac ? '⌃' : 'Ctrl'
|
||||
// case 'Command':
|
||||
// return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
// case 'Alt':
|
||||
// return isMac ? '⌥' : 'Alt'
|
||||
// case 'Shift':
|
||||
// return isMac ? '⇧' : 'Shift'
|
||||
// case 'CommandOrControl':
|
||||
// return isMac ? '⌘' : 'Ctrl'
|
||||
|
||||
// new way for modifier keys
|
||||
case 'CommandOrControl':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case 'Ctrl':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
case 'Alt':
|
||||
return isMac ? '⌥' : 'Alt'
|
||||
case 'Meta':
|
||||
return isMac ? '⌘' : isWin ? 'Win' : 'Super'
|
||||
case 'Shift':
|
||||
return isMac ? '⇧' : 'Shift'
|
||||
|
||||
// for backward compatibility with old data
|
||||
case 'Command':
|
||||
case 'Cmd':
|
||||
return isMac ? '⌘' : 'Ctrl'
|
||||
case 'Control':
|
||||
return isMac ? '⌃' : 'Ctrl'
|
||||
|
||||
case 'ArrowUp':
|
||||
return '↑'
|
||||
case 'ArrowDown':
|
||||
return '↓'
|
||||
case 'ArrowLeft':
|
||||
return '←'
|
||||
case 'ArrowRight':
|
||||
return '→'
|
||||
case 'Slash':
|
||||
return '/'
|
||||
case 'Semicolon':
|
||||
return ';'
|
||||
case 'BracketLeft':
|
||||
return '['
|
||||
case 'BracketRight':
|
||||
return ']'
|
||||
case 'Backslash':
|
||||
return '\\'
|
||||
case 'Quote':
|
||||
return "'"
|
||||
case 'Comma':
|
||||
return ','
|
||||
case 'Minus':
|
||||
return '-'
|
||||
case 'Equal':
|
||||
return '='
|
||||
default:
|
||||
return key.charAt(0).toUpperCase() + key.slice(1)
|
||||
}
|
||||
})
|
||||
.join(' + ')
|
||||
}
|
||||
|
||||
const usableEndKeys = (event: React.KeyboardEvent): string | null => {
|
||||
const usableEndKeys = (event: ReactKeyboardEvent): string | null => {
|
||||
const { code } = event
|
||||
// No lock keys
|
||||
// Among the commonly used keys, not including: Escape, NumpadMultiply, NumpadDivide, NumpadSubtract, NumpadAdd, NumpadDecimal
|
||||
// The react-hotkeys-hook library does not differentiate between `Digit` and `Numpad`
|
||||
switch (code) {
|
||||
case 'KeyA':
|
||||
case 'KeyB':
|
||||
@@ -217,10 +194,15 @@ const ShortcutSettings: FC = () => {
|
||||
case 'Numpad9':
|
||||
return code.slice(-1)
|
||||
case 'Space':
|
||||
return 'Space'
|
||||
case 'Enter':
|
||||
return 'Enter'
|
||||
case 'Backspace':
|
||||
return 'Backspace'
|
||||
case 'Tab':
|
||||
return 'Tab'
|
||||
case 'Delete':
|
||||
return 'Delete'
|
||||
case 'PageUp':
|
||||
case 'PageDown':
|
||||
case 'Insert':
|
||||
@@ -256,7 +238,6 @@ const ShortcutSettings: FC = () => {
|
||||
return '.'
|
||||
case 'NumpadEnter':
|
||||
return 'Enter'
|
||||
// The react-hotkeys-hook library does not handle the symbol strings for the following keys
|
||||
case 'Slash':
|
||||
case 'Semicolon':
|
||||
case 'BracketLeft':
|
||||
@@ -272,28 +253,19 @@ const ShortcutSettings: FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent, record: Shortcut) => {
|
||||
e.preventDefault()
|
||||
const handleKeyDown = (event: ReactKeyboardEvent, record: ShortcutRecord) => {
|
||||
event.preventDefault()
|
||||
|
||||
const keys: string[] = []
|
||||
|
||||
// OLD WAY FOR MODIFIER KEYS, KEEP THEM HERE FOR REFERENCE
|
||||
// if (e.ctrlKey) keys.push(isMac ? 'Control' : 'Ctrl')
|
||||
// if (e.metaKey) keys.push('Command')
|
||||
// if (e.altKey) keys.push('Alt')
|
||||
// if (e.shiftKey) keys.push('Shift')
|
||||
if (event.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl')
|
||||
if (event.altKey) keys.push('Alt')
|
||||
if (event.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta')
|
||||
if (event.shiftKey) keys.push('Shift')
|
||||
|
||||
// NEW WAY FOR MODIFIER KEYS
|
||||
// for capability across platforms, we transform the modifier keys to the really meaning keys
|
||||
// mainly consider the habit of users on different platforms
|
||||
if (e.ctrlKey) keys.push(isMac ? 'Ctrl' : 'CommandOrControl') // for win&linux, ctrl key is almost the same as command key in macOS
|
||||
if (e.altKey) keys.push('Alt')
|
||||
if (e.metaKey) keys.push(isMac ? 'CommandOrControl' : 'Meta') // for macOS, meta(Command) key is almost the same as Ctrl key in win&linux
|
||||
if (e.shiftKey) keys.push('Shift')
|
||||
|
||||
const endKey = usableEndKeys(e)
|
||||
const endKey = usableEndKeys(event)
|
||||
if (endKey) {
|
||||
keys.push(endKey)
|
||||
keys.push(convertKeyToAccelerator(endKey))
|
||||
}
|
||||
|
||||
if (!isValidShortcut(keys)) {
|
||||
@@ -304,7 +276,7 @@ const ShortcutSettings: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
dispatch(updateShortcut({ ...record, shortcut: keys }))
|
||||
void record.updatePreference({ key: keys })
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
@@ -312,50 +284,60 @@ const ShortcutSettings: FC = () => {
|
||||
window.modal.confirm({
|
||||
title: t('settings.shortcuts.reset_defaults_confirm'),
|
||||
centered: true,
|
||||
onOk: () => dispatch(resetShortcuts())
|
||||
onOk: async () => {
|
||||
const updates: Record<string, PreferenceShortcutType> = {}
|
||||
|
||||
shortcuts.forEach((item) => {
|
||||
updates[item.definition.key] = {
|
||||
key: item.defaultPreference.binding,
|
||||
enabled: item.defaultPreference.enabled,
|
||||
editable: item.defaultPreference.editable,
|
||||
system: item.defaultPreference.system
|
||||
}
|
||||
})
|
||||
|
||||
await preferenceService.setMultiple(updates)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 由于启用了showHeader = false,不再需要title字段
|
||||
const columns: ColumnsType<Shortcut> = [
|
||||
const columns: ColumnsType<ShortcutRecord> = [
|
||||
{
|
||||
// title: t('settings.shortcuts.action'),
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
dataIndex: 'label',
|
||||
key: 'label'
|
||||
},
|
||||
{
|
||||
// title: t('settings.shortcuts.label'),
|
||||
dataIndex: 'shortcut',
|
||||
dataIndex: 'displayKeys',
|
||||
key: 'shortcut',
|
||||
align: 'right',
|
||||
render: (shortcut: string[], record: Shortcut) => {
|
||||
const isEditing = editingKey === record.key
|
||||
const shortcutConfig = shortcuts.find((s) => s.key === record.key)
|
||||
const isEditable = shortcutConfig?.editable !== false
|
||||
render: (_value, record) => {
|
||||
const isEditing = editingKey === record.id
|
||||
const displayShortcut = record.displayKeys.length > 0 ? formatShortcutDisplay(record.displayKeys, isMac) : ''
|
||||
const editingShortcut = record.rawKeys.length > 0 ? formatShortcutDisplay(record.rawKeys, isMac) : ''
|
||||
|
||||
return (
|
||||
<RowFlex className="items-center justify-end gap-2">
|
||||
<RowFlex className="relative items-center">
|
||||
{isEditing ? (
|
||||
<ShortcutInput
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
inputRefs.current[record.key] = el
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
inputRefs.current[record.id] = element
|
||||
}
|
||||
}}
|
||||
value={formatShortcut(shortcut)}
|
||||
value={editingShortcut}
|
||||
placeholder={t('settings.shortcuts.press_shortcut')}
|
||||
onKeyDown={(e) => handleKeyDown(e, record)}
|
||||
onBlur={(e) => {
|
||||
const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon')
|
||||
onKeyDown={(event) => handleKeyDown(event, record)}
|
||||
onBlur={(event) => {
|
||||
const isUndoClick = event.relatedTarget?.closest('.shortcut-undo-icon')
|
||||
if (!isUndoClick) {
|
||||
setEditingKey(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ShortcutText isEditable={isEditable} onClick={() => isEditable && handleAddShortcut(record)}>
|
||||
{shortcut.length > 0 ? formatShortcut(shortcut) : t('settings.shortcuts.press_shortcut')}
|
||||
<ShortcutText isEditable={record.editable} onClick={() => record.editable && handleAddShortcut(record)}>
|
||||
{displayShortcut || t('settings.shortcuts.press_shortcut')}
|
||||
</ShortcutText>
|
||||
)}
|
||||
</RowFlex>
|
||||
@@ -364,11 +346,10 @@ const ShortcutSettings: FC = () => {
|
||||
}
|
||||
},
|
||||
{
|
||||
// title: t('settings.shortcuts.actions'),
|
||||
key: 'actions',
|
||||
align: 'right',
|
||||
width: '70px',
|
||||
render: (record: Shortcut) => (
|
||||
width: 70,
|
||||
render: (record) => (
|
||||
<RowFlex className="items-center justify-end gap-2">
|
||||
<Tooltip content={t('settings.shortcuts.reset_to_default')}>
|
||||
<Button size="icon-sm" onClick={() => handleResetShortcut(record)} disabled={!isShortcutModified(record)}>
|
||||
@@ -379,7 +360,7 @@ const ShortcutSettings: FC = () => {
|
||||
<Button
|
||||
size="icon-sm"
|
||||
onClick={() => handleClear(record)}
|
||||
disabled={record.shortcut.length === 0 || !record.editable}>
|
||||
disabled={record.rawKeys.length === 0 || !record.editable}>
|
||||
<ClearOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -387,12 +368,15 @@ const ShortcutSettings: FC = () => {
|
||||
)
|
||||
},
|
||||
{
|
||||
// title: t('settings.shortcuts.enabled'),
|
||||
key: 'enabled',
|
||||
align: 'right',
|
||||
width: '50px',
|
||||
render: (record: Shortcut) => (
|
||||
<Switch size="sm" isSelected={record.enabled} onValueChange={() => dispatch(toggleShortcut(record.key))} />
|
||||
width: 50,
|
||||
render: (record) => (
|
||||
<Switch
|
||||
size="sm"
|
||||
isSelected={record.enabled}
|
||||
onValueChange={() => void record.updatePreference({ enabled: !record.enabled })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -404,10 +388,11 @@ const ShortcutSettings: FC = () => {
|
||||
<SettingDivider style={{ marginBottom: 0 }} />
|
||||
<Table
|
||||
columns={columns as ColumnsType<unknown>}
|
||||
dataSource={shortcuts.map((s) => ({ ...s, name: getShortcutLabel(s.key) }))}
|
||||
dataSource={displayedShortcuts}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
showHeader={false}
|
||||
rowKey="id"
|
||||
/>
|
||||
<SettingDivider style={{ marginBottom: 0 }} />
|
||||
<RowFlex className="justify-end p-4">
|
||||
|
||||
Reference in New Issue
Block a user