feat: add shortcut feature

This commit is contained in:
kangfenmao 2024-12-02 16:23:40 +08:00
parent 744a6ac7cb
commit 7dacd58821
19 changed files with 470 additions and 135 deletions

View File

@ -23,7 +23,7 @@ export default defineConfig({
},
plugins: [react()],
optimizeDeps: {
exclude: ['chunk-KNVOMWSO.js']
exclude: ['chunk-KNVOMWSO.js', 'chunk-2NJP6ETL.js']
}
}
})

View File

@ -96,6 +96,7 @@
"prettier": "^3.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hotkeys-hook": "^4.6.1",
"react-i18next": "^14.1.2",
"react-markdown": "^9.0.1",
"react-redux": "^9.1.2",

View File

@ -3,7 +3,7 @@ import { app, BrowserWindow } from 'electron'
import installExtension, { REDUX_DEVTOOLS } from 'electron-devtools-installer'
import { registerIpc } from './ipc'
import { registerZoomShortcut } from './services/ShortcutService'
import { registerShortcuts } from './services/ShortcutService'
import { TrayService } from './services/TrayService'
import { windowService } from './services/WindowService'
import { updateUserDataPath } from './utils/upgrade'
@ -35,7 +35,7 @@ if (!app.requestSingleInstanceLock()) {
}
})
registerZoomShortcut(mainWindow)
registerShortcuts(mainWindow)
registerIpc(mainWindow, app)

View File

@ -1,7 +1,7 @@
import fs from 'node:fs'
import path from 'node:path'
import { ThemeMode } from '@types'
import { Shortcut, ThemeMode } from '@types'
import { BrowserWindow, ipcMain, ProxyConfig, session, shell } from 'electron'
import log from 'electron-log'
@ -11,6 +11,7 @@ import BackupManager from './services/BackupManager'
import { configManager } from './services/ConfigManager'
import { ExportService } from './services/ExportService'
import FileStorage from './services/FileStorage'
import { registerShortcuts, unregisterAllShortcuts } from './services/ShortcutService'
import { windowService } from './services/WindowService'
import { compress, decompress } from './utils/zip'
@ -133,4 +134,14 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('open:path', async (_, path: string) => {
await shell.openPath(path)
})
// shortcuts
ipcMain.handle('shortcuts:update', (_, shortcuts: Shortcut[]) => {
configManager.setShortcuts(shortcuts)
// Refresh shortcuts registration
if (mainWindow) {
unregisterAllShortcuts()
registerShortcuts(mainWindow)
}
})
}

View File

@ -70,6 +70,15 @@ export class ConfigManager {
subscribers.forEach((subscriber) => subscriber(newValue))
}
}
getShortcuts() {
return this.store.get('shortcuts') as Shortcut[] | undefined
}
setShortcuts(shortcuts: Shortcut[]) {
this.store.set('shortcuts', shortcuts)
this.notifySubscribers('shortcuts', shortcuts)
}
}
export const configManager = new ConfigManager()

View File

@ -1,70 +1,103 @@
import { Shortcut } from '@types'
import { BrowserWindow, globalShortcut } from 'electron'
import Logger from 'electron-log'
import { configManager } from './ConfigManager'
export function registerZoomShortcut(mainWindow: BrowserWindow) {
// 初始化缩放值
const initialZoom = configManager.getZoomFactor()
mainWindow.webContents.setZoomFactor(initialZoom)
let showAppAccelerator: string | null = null
const handleZoom = (delta: number) => {
if (mainWindow) {
const currentZoom = mainWindow.webContents.getZoomFactor()
function getShortcutHandler(shortcut: Shortcut) {
switch (shortcut.key) {
case 'zoom_in':
return () => handleZoom(0.1)
case 'zoom_out':
return () => handleZoom(-0.1)
case 'zoom_reset':
return (window: BrowserWindow) => {
window.webContents.setZoomFactor(1)
configManager.setZoomFactor(1)
}
case 'show_app':
return (window: BrowserWindow) => {
if (window.isVisible()) {
window.hide()
} else {
window.show()
window.focus()
}
}
default:
return null
}
}
function formatShortcutKey(shortcut: string[]): string {
return shortcut.join('+')
}
function handleZoom(delta: number) {
return (window: BrowserWindow) => {
const currentZoom = window.webContents.getZoomFactor()
const newZoom = currentZoom + delta
if (newZoom >= 0.1 && newZoom <= 5.0) {
mainWindow.webContents.setZoomFactor(newZoom)
window.webContents.setZoomFactor(newZoom)
configManager.setZoomFactor(newZoom)
}
}
}
const registerShortcuts = () => {
// 放大快捷键
globalShortcut.register('CommandOrControl+=', () => handleZoom(0.1))
globalShortcut.register('CommandOrControl+numadd', () => handleZoom(0.1))
function registerWindowShortcuts(window: BrowserWindow) {
window.webContents.setZoomFactor(configManager.getZoomFactor())
// 缩小快捷键
globalShortcut.register('CommandOrControl+-', () => handleZoom(-0.1))
globalShortcut.register('CommandOrControl+numsub', () => handleZoom(-0.1))
const register = () => {
if (window.isDestroyed()) return
// 重置快捷键
globalShortcut.register('CommandOrControl+0', () => {
if (mainWindow) {
mainWindow.webContents.setZoomFactor(1)
configManager.setZoomFactor(1)
const shortcuts = configManager.getShortcuts()
if (!shortcuts) return
shortcuts.forEach((shortcut) => {
if (!shortcut.enabled || shortcut.shortcut.length === 0) return
const handler = getShortcutHandler(shortcut)
if (!handler) return
const accelerator = formatShortcutKey(shortcut.shortcut)
if (shortcut.key === 'show_app') {
showAppAccelerator = accelerator
}
Logger.info(`Register shortcut: ${accelerator}`)
globalShortcut.register(accelerator, () => handler(window))
})
}
const unregisterShortcuts = () => {
globalShortcut.unregister('CommandOrControl+=')
globalShortcut.unregister('CommandOrControl+numadd')
globalShortcut.unregister('CommandOrControl+-')
globalShortcut.unregister('CommandOrControl+numsub')
globalShortcut.unregister('CommandOrControl+0')
const unregister = () => {
if (window.isDestroyed()) return
globalShortcut.unregisterAll()
if (showAppAccelerator) {
const handler = getShortcutHandler({ key: 'show_app' } as Shortcut)
if (handler) {
globalShortcut.register(showAppAccelerator, () => handler(window))
}
}
}
// Add check for window destruction
if (mainWindow.isDestroyed()) {
return
window.on('focus', () => register())
window.on('blur', () => unregister())
if (!window.isDestroyed() && window.isFocused()) {
register()
}
}
// When window gains focus, register shortcuts
mainWindow.on('focus', () => {
if (!mainWindow.isDestroyed()) {
registerShortcuts()
export function registerShortcuts(mainWindow: BrowserWindow) {
registerWindowShortcuts(mainWindow)
}
})
// 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 unregisterAllShortcuts() {
showAppAccelerator = null
globalShortcut.unregisterAll()
}

View File

@ -54,6 +54,9 @@ declare global {
toWord: (markdown: string, fileName: string) => Promise<void>
}
openPath: (path: string) => Promise<void>
shortcuts: {
update: (shortcuts: Shortcut[]) => Promise<void>
}
}
}
}

View File

@ -1,5 +1,5 @@
import { electronAPI } from '@electron-toolkit/preload'
import { WebDavConfig } from '@types'
import { Shortcut, WebDavConfig } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions } from 'electron'
// Custom APIs for renderer
@ -47,7 +47,10 @@ const api = {
export: {
toWord: (markdown: string, fileName: string) => ipcRenderer.invoke('export:word', markdown, fileName)
},
openPath: (path: string) => ipcRenderer.invoke('open:path', path)
openPath: (path: string) => ipcRenderer.invoke('open:path', path),
shortcuts: {
update: (shortcuts: Shortcut[]) => ipcRenderer.invoke('shortcuts:update', shortcuts)
}
}
// Use `contextBridge` APIs to expose Electron APIs to

View 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
}
)
}

View File

@ -142,7 +142,9 @@
"select": "Select",
"topics": "Topics",
"warning": "Warning",
"you": "You"
"you": "You",
"clear": "Clear",
"add": "Add"
},
"error": {
"backup.file_format": "Backup file format error",
@ -451,7 +453,10 @@
"title": "Keyboard Shortcuts",
"zoom_in": "Zoom In",
"zoom_out": "Zoom Out",
"zoom_reset": "Reset Zoom"
"zoom_reset": "Reset Zoom",
"show_app": "Show App",
"reset_defaults": "Reset Defaults",
"press_shortcut": "Press Shortcut"
},
"theme.auto": "Auto",
"theme.dark": "Dark",

View File

@ -142,7 +142,9 @@
"select": "Выбрать",
"topics": "Топики",
"warning": "Предупреждение",
"you": "Вы"
"you": "Вы",
"clear": "Очистить",
"add": "Добавить"
},
"error": {
"backup.file_format": "Ошибка формата файла резервной копии",
@ -451,7 +453,10 @@
"title": "Горячие клавиши",
"zoom_in": "Увеличить",
"zoom_out": "Уменьшить",
"zoom_reset": "Сбросить масштаб"
"zoom_reset": "Сбросить масштаб",
"show_app": "Показать приложение",
"reset_defaults": "Сбросить настройки по умолчанию",
"press_shortcut": "Нажмите сочетание клавиш"
},
"theme.auto": "Автоматически",
"theme.dark": "Темная",

View File

@ -142,7 +142,9 @@
"select": "选择",
"topics": "话题",
"warning": "警告",
"you": "用户"
"you": "用户",
"clear": "清除",
"add": "添加"
},
"error": {
"backup.file_format": "备份文件格式错误",
@ -436,10 +438,13 @@
"action": "操作",
"key": "按键",
"new_topic": "新建话题",
"title": "快捷方式",
"title": "快捷",
"zoom_in": "放大界面",
"zoom_out": "缩小界面",
"zoom_reset": "重置缩放"
"zoom_reset": "重置缩放",
"show_app": "显示应用",
"reset_defaults": "重置默认快捷键",
"press_shortcut": "按下快捷键"
},
"theme.auto": "跟随系统",
"theme.dark": "深色主题",

View File

@ -142,7 +142,9 @@
"select": "選擇",
"topics": "話題",
"warning": "警告",
"you": "您"
"you": "您",
"clear": "清除",
"add": "添加"
},
"error": {
"backup.file_format": "備份文件格式錯誤",
@ -439,7 +441,10 @@
"title": "快速方式",
"zoom_in": "放大界面",
"zoom_out": "縮小界面",
"zoom_reset": "重置縮放"
"zoom_reset": "重置縮放",
"show_app": "顯示應用",
"reset_defaults": "重置預設快捷鍵",
"press_shortcut": "按下快捷鍵"
},
"theme.auto": "自動",
"theme.dark": "深色主題",

View File

@ -15,6 +15,7 @@ import db from '@renderer/databases'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services/AssistantService'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
@ -316,20 +317,13 @@ const Inputbar: FC<Props> = ({ assistant, setActiveTopic }) => {
setTimeout(() => resizeTextArea(), 0)
}
// Command or Ctrl + N create new topic
useEffect(() => {
const onKeydown = (e) => {
useShortcut('new_topic', () => {
if (!generating) {
if ((e.ctrlKey || e.metaKey) && e.key === 'n') {
addNewTopic()
EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR)
textareaRef.current?.focus()
}
}
}
document.addEventListener('keydown', onKeydown)
return () => document.removeEventListener('keydown', onKeydown)
}, [addNewTopic, generating])
})
useEffect(() => {
const _setEstimateTokenCount = debounce(setEstimateTokenCount, 100, { leading: false, trailing: true })

View File

@ -1,8 +1,11 @@
import { UndoOutlined } from '@ant-design/icons'
import { isMac } from '@renderer/config/constant'
import { useTheme } from '@renderer/context/ThemeProvider'
import { Switch, Table as AntTable, Tag } from 'antd'
import { useAppDispatch, useAppSelector } from '@renderer/store'
import { initialState, resetShortcuts, updateShortcut } from '@renderer/store/shortcuts'
import { Button, Input, InputRef, Table as AntTable } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { FC } from 'react'
import { FC, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -11,74 +14,179 @@ import { SettingContainer, SettingDivider, SettingGroup, SettingTitle } from '.'
interface ShortcutItem {
key: string
name: string
shortcut: string
shortcut: string[]
enabled: boolean
}
const ShortcutSettings: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const shortcuts = useAppSelector((state) => state.shortcuts.shortcuts)
const inputRefs = useRef<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> = [
{
title: t('settings.shortcuts.action'),
dataIndex: 'name',
key: 'name',
width: '50%'
key: 'name'
},
{
title: t('settings.shortcuts.key'),
dataIndex: 'shortcut',
key: 'shortcut',
width: '30%',
render: (shortcut: string) => {
const keys = shortcut.split(' ').map((key) => key.trim())
align: 'right',
render: (shortcut: string[], record: ShortcutItem) => {
const isEditing = editingKey === record.key
return (
<span>
{keys.map((key) => (
<Tag key={key} style={{ padding: '2px 8px', fontSize: '13px' }}>
<span style={{ fontFamily: 'monospace' }}>{key}</span>
</Tag>
))}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ position: 'relative', flex: 1 }}>
{isEditing ? (
<Input
ref={(el) => el && (inputRefs.current[record.key] = el)}
value={formatShortcut(shortcut)}
placeholder={t('settings.shortcuts.press_shortcut')}
onKeyDown={(e) => handleKeyDown(e, record)}
onBlur={(e) => {
const isUndoClick = e.relatedTarget?.closest('.shortcut-undo-icon')
if (!isUndoClick) {
setEditingKey(null)
}
}}
style={{ width: '120px' }}
suffix={
isShortcutModified(record) && (
<UndoOutlined
className="shortcut-undo-icon"
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
cursor: 'pointer',
color: '#999'
}}
onClick={() => {
handleResetShortcut(record)
setEditingKey(null)
}}
/>
)
}
},
{
title: '',
key: 'enabled',
width: '20%',
align: 'right',
render: () => <Switch defaultChecked disabled />
/>
) : (
<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>
)
}
]
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 }} />
<Table
columns={columns as ColumnsType<unknown>}
dataSource={shortcuts}
dataSource={shortcuts.map((s) => ({ ...s, name: t(s.name) }))}
pagination={false}
size="middle"
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>
</SettingContainer>
)

View File

@ -10,6 +10,7 @@ import migrate from './migrate'
import paintings from './paintings'
import runtime from './runtime'
import settings from './settings'
import shortcuts from './shortcuts'
const rootReducer = combineReducers({
assistants,
@ -17,7 +18,8 @@ const rootReducer = combineReducers({
paintings,
llm,
settings,
runtime
runtime,
shortcuts
})
const persistedReducer = persistReducer(

View 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 }

View File

@ -162,3 +162,10 @@ export type AppInfo = {
filesPath: string
logsPath: string
}
export interface Shortcut {
key: string
name: string
shortcut: string[]
enabled: boolean
}

View File

@ -2383,6 +2383,7 @@ __metadata:
prettier: "npm:^3.2.4"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-hotkeys-hook: "npm:^4.6.1"
react-i18next: "npm:^14.1.2"
react-markdown: "npm:^9.0.1"
react-redux: "npm:^9.1.2"
@ -9955,6 +9956,16 @@ __metadata:
languageName: node
linkType: hard
"react-hotkeys-hook@npm:^4.6.1":
version: 4.6.1
resolution: "react-hotkeys-hook@npm:4.6.1"
peerDependencies:
react: ">=16.8.1"
react-dom: ">=16.8.1"
checksum: 10c0/e23916567b0b863831cf7e2ddba591e82988a3212a9e4b818ac7da6d751b7e8b102f2245d8bf7e9526a97766ffd092efdbfbcc457b74564415f5d5f8ab51919e
languageName: node
linkType: hard
"react-i18next@npm:^14.1.2":
version: 14.1.3
resolution: "react-i18next@npm:14.1.3"