feat: add shortcut to home window of mini app

This commit is contained in:
icinggslits 2025-02-24 00:29:05 +08:00 committed by 亢奋猫
parent 28c0748001
commit 91bf356c73
3 changed files with 140 additions and 74 deletions

View File

@ -7,7 +7,7 @@ import { uuid } from '@renderer/utils'
import { Divider } from 'antd'
import dayjs from 'dayjs'
import { isEmpty } from 'lodash'
import React, { FC, useCallback, useEffect, useState } from 'react'
import React, { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -15,7 +15,7 @@ import styled from 'styled-components'
import ChatWindow from '../chat/ChatWindow'
import TranslateWindow from '../translate/TranslateWindow'
import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus from './components/FeatureMenus'
import FeatureMenus, { FeatureMenusRef } from './components/FeatureMenus'
import Footer from './components/Footer'
import InputBar from './components/InputBar'
@ -31,12 +31,14 @@ const HomeWindow: FC = () => {
const { defaultModel: model } = useDefaultModel()
const { language } = useSettings()
const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
const referenceText = selectedText || clipboardText || text
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim()
const onReadClipboard = useCallback(async () => {
const readClipboard = useCallback(async () => {
const text = await navigator.clipboard.readText().catch(() => null)
if (text && text !== lastClipboardText) {
setLastClipboardText(text)
@ -44,9 +46,24 @@ const HomeWindow: FC = () => {
}
}, [lastClipboardText])
const focusInput = () => {
if (inputBarRef.current) {
const input = inputBarRef.current.querySelector('input')
if (input) {
input.focus()
}
}
}
const onWindowShow = useCallback(async () => {
featureMenusRef.current?.resetSelectedIndex()
readClipboard().then()
focusInput()
}, [readClipboard])
useEffect(() => {
onReadClipboard()
}, [onReadClipboard])
readClipboard()
}, [readClipboard])
useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US')
@ -55,31 +72,65 @@ const HomeWindow: FC = () => {
const onCloseWindow = () => window.api.miniWindow.hide()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const isEnterPressed = e.code === 'Enter'
const isBackspacePressed = e.code === 'Backspace'
if (e.code === 'Escape') {
setText('')
setRoute('home')
route === 'home' && onCloseWindow()
// 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
// 键入过程不应有任何响应
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
// 输入法可以`Esc`终止候选词过程
// 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
if (e.key === 'Process') {
return
}
if (isEnterPressed) {
e.preventDefault()
if (content) {
setRoute('chat')
onSendMessage()
setTimeout(() => setText(''), 100)
}
}
if (isBackspacePressed) {
textChange(() => {
if (text.length === 0) {
clearClipboard()
switch (e.code) {
case 'Enter':
{
e.preventDefault()
if (content) {
if (route === 'home') {
featureMenusRef.current?.useFeature()
setText('')
} else {
// 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat'
setRoute('chat')
onSendMessage().then()
focusInput()
setTimeout(() => setText(''), 100)
}
}
}
})
break
case 'Backspace':
{
textChange(() => {
if (text.length === 0) {
clearClipboard()
}
})
}
break
case 'ArrowUp':
{
if (route === 'home') {
e.preventDefault()
featureMenusRef.current?.prevFeature()
}
}
break
case 'ArrowDown':
{
if (route === 'home') {
e.preventDefault()
featureMenusRef.current?.nextFeature()
}
}
break
case 'Escape':
{
setText('')
setRoute('home')
route === 'home' && onCloseWindow()
}
break
}
}
@ -116,6 +167,7 @@ const HomeWindow: FC = () => {
setSelectedText('')
}
// If the input is focused, the `Esc` callback will not be triggered here.
useHotkeys('esc', () => {
if (route === 'home') {
onCloseWindow()
@ -126,7 +178,7 @@ const HomeWindow: FC = () => {
})
useEffect(() => {
window.electron.ipcRenderer.on('show-mini-window', onReadClipboard)
window.electron.ipcRenderer.on('show-mini-window', onWindowShow)
window.electron.ipcRenderer.on('selection-action', (_, { action, selectedText }) => {
selectedText && setSelectedText(selectedText)
action && setRoute(action)
@ -137,7 +189,7 @@ const HomeWindow: FC = () => {
window.electron.ipcRenderer.removeAllListeners('show-mini-window')
window.electron.ipcRenderer.removeAllListeners('selection-action')
}
}, [onReadClipboard, onSendMessage, setRoute])
}, [onWindowShow, onSendMessage, setRoute])
// 当路由为home时初始化isFirstMessage为true
useEffect(() => {
@ -158,6 +210,7 @@ const HomeWindow: FC = () => {
placeholder={t('miniwindow.input.placeholder.empty', { model: model.name })}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
</>
@ -197,11 +250,12 @@ const HomeWindow: FC = () => {
}
handleKeyDown={handleKeyDown}
handleChange={handleChange}
ref={inputBarRef}
/>
<Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main>
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} />
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
</Main>
<Divider style={{ margin: '10px 0' }} />
<Footer

View File

@ -1,7 +1,7 @@
import { BulbOutlined, FileTextOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
import { BulbOutlined, EnterOutlined, FileTextOutlined, MessageOutlined, TranslationOutlined } from '@ant-design/icons'
import Scrollbar from '@renderer/components/Scrollbar'
import { Col } from 'antd'
import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react'
import { Dispatch, forwardRef, SetStateAction, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -11,7 +11,14 @@ interface FeatureMenusProps {
onSendMessage: (prompt?: string) => void
}
const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage }) => {
export interface FeatureMenusRef {
nextFeature: () => void
prevFeature: () => void
useFeature: () => void
resetSelectedIndex: () => void
}
const FeatureMenus = forwardRef<FeatureMenusRef, FeatureMenusProps>(({ text, setRoute, onSendMessage }, ref) => {
const { t } = useTranslation()
const [selectedIndex, setSelectedIndex] = useState(0)
@ -57,27 +64,20 @@ const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage })
[onSendMessage, setRoute, t, text]
)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : features.length - 1))
break
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) => (prev < features.length - 1 ? prev + 1 : 0))
break
case 'Enter':
e.preventDefault()
features[selectedIndex].onClick?.()
break
}
useImperativeHandle(ref, () => ({
nextFeature() {
setSelectedIndex((prev) => (prev < features.length - 1 ? prev + 1 : 0))
},
prevFeature() {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : features.length - 1))
},
useFeature() {
features[selectedIndex].onClick?.()
},
resetSelectedIndex() {
setSelectedIndex(0)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [features, selectedIndex])
}))
return (
<FeatureList>
@ -87,13 +87,15 @@ const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage })
<FeatureItem onClick={feature.onClick} className={index === selectedIndex ? 'active' : ''}>
<FeatureIcon>{feature.icon}</FeatureIcon>
<FeatureTitle>{feature.title}</FeatureTitle>
{index === selectedIndex && <EnterOutlined />}
</FeatureItem>
</Col>
))}
</FeatureListWrapper>
</FeatureList>
)
}
})
FeatureMenus.displayName = 'FeatureMenus'
const FeatureList = styled(Scrollbar)`
flex: 1;
@ -109,7 +111,7 @@ const FeatureListWrapper = styled.div`
const FeatureItem = styled.div`
cursor: pointer;
transition: all 0.3s;
transition: background-color 0s;
background: transparent;
border: none;
padding: 8px 16px;
@ -121,11 +123,11 @@ const FeatureItem = styled.div`
user-select: none;
&:hover {
background: var(--color-background-opacity);
background: var(--color-background-mute);
}
&.active {
background: var(--color-background-opacity);
background: var(--color-background-mute);
}
`
@ -136,6 +138,7 @@ const FeatureIcon = styled.div`
const FeatureTitle = styled.h3`
margin: 0;
font-size: 14px;
flex-basis: 100%;
`
export default FeatureMenus

View File

@ -1,7 +1,8 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { useRuntime } from '@renderer/hooks/useRuntime'
import { Input as AntdInput } from 'antd'
import React, { FC } from 'react'
import { InputRef } from 'rc-input/lib/interface'
import React, { forwardRef, useRef } from 'react'
import styled from 'styled-components'
interface InputBarProps {
@ -13,23 +14,31 @@ interface InputBarProps {
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}
const InputBar: FC<InputBarProps> = ({ text, model, placeholder, handleKeyDown, handleChange }) => {
const { generating } = useRuntime()
return (
<InputWrapper>
<ModelAvatar model={model} size={30} />
<Input
value={text}
placeholder={placeholder}
bordered={false}
autoFocus
onKeyDown={handleKeyDown}
onChange={handleChange}
disabled={generating}
/>
</InputWrapper>
)
}
const InputBar = forwardRef<HTMLDivElement, InputBarProps>(
({ text, model, placeholder, handleKeyDown, handleChange }, ref) => {
const { generating } = useRuntime()
const inputRef = useRef<InputRef>(null)
if (!generating) {
setTimeout(() => inputRef.current?.input?.focus(), 0)
}
return (
<InputWrapper ref={ref}>
<ModelAvatar model={model} size={30} />
<Input
value={text}
placeholder={placeholder}
bordered={false}
autoFocus
onKeyDown={handleKeyDown}
onChange={handleChange}
disabled={generating}
ref={inputRef}
/>
</InputWrapper>
)
}
)
InputBar.displayName = 'InputBar'
const InputWrapper = styled.div`
display: flex;