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 { Divider } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { isEmpty } from 'lodash' 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 { useHotkeys } from 'react-hotkeys-hook'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -15,7 +15,7 @@ import styled from 'styled-components'
import ChatWindow from '../chat/ChatWindow' import ChatWindow from '../chat/ChatWindow'
import TranslateWindow from '../translate/TranslateWindow' import TranslateWindow from '../translate/TranslateWindow'
import ClipboardPreview from './components/ClipboardPreview' import ClipboardPreview from './components/ClipboardPreview'
import FeatureMenus from './components/FeatureMenus' import FeatureMenus, { FeatureMenusRef } from './components/FeatureMenus'
import Footer from './components/Footer' import Footer from './components/Footer'
import InputBar from './components/InputBar' import InputBar from './components/InputBar'
@ -31,12 +31,14 @@ const HomeWindow: FC = () => {
const { defaultModel: model } = useDefaultModel() const { defaultModel: model } = useDefaultModel()
const { language } = useSettings() const { language } = useSettings()
const { t } = useTranslation() const { t } = useTranslation()
const inputBarRef = useRef<HTMLDivElement>(null)
const featureMenusRef = useRef<FeatureMenusRef>(null)
const referenceText = selectedText || clipboardText || text const referenceText = selectedText || clipboardText || text
const content = isFirstMessage ? (referenceText === text ? text : `${referenceText}\n\n${text}`).trim() : text.trim() 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) const text = await navigator.clipboard.readText().catch(() => null)
if (text && text !== lastClipboardText) { if (text && text !== lastClipboardText) {
setLastClipboardText(text) setLastClipboardText(text)
@ -44,9 +46,24 @@ const HomeWindow: FC = () => {
} }
}, [lastClipboardText]) }, [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(() => { useEffect(() => {
onReadClipboard() readClipboard()
}, [onReadClipboard]) }, [readClipboard])
useEffect(() => { useEffect(() => {
i18n.changeLanguage(language || navigator.language || 'en-US') i18n.changeLanguage(language || navigator.language || 'en-US')
@ -55,31 +72,65 @@ const HomeWindow: FC = () => {
const onCloseWindow = () => window.api.miniWindow.hide() const onCloseWindow = () => window.api.miniWindow.hide()
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const isEnterPressed = e.code === 'Enter' // 使用非直接输入法时(例如中文、日文输入法),存在输入法键入过程
const isBackspacePressed = e.code === 'Backspace' // 键入过程不应有任何响应
// 例子,中文输入法候选词过程使用`Enter`直接上屏字母,日文输入法候选词过程使用`Enter`输入假名
if (e.code === 'Escape') { // 输入法可以`Esc`终止候选词过程
setText('') // 这两个例子的`Enter`和`Esc`快捷助手都不应该响应
setRoute('home') if (e.key === 'Process') {
route === 'home' && onCloseWindow()
return return
} }
if (isEnterPressed) { switch (e.code) {
e.preventDefault() case 'Enter':
if (content) { {
setRoute('chat') e.preventDefault()
onSendMessage() if (content) {
setTimeout(() => setText(''), 100) if (route === 'home') {
} featureMenusRef.current?.useFeature()
} setText('')
} else {
if (isBackspacePressed) { // 目前文本框只在'chat'时可以继续输入,这里相当于 route === 'chat'
textChange(() => { setRoute('chat')
if (text.length === 0) { onSendMessage().then()
clearClipboard() 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('') setSelectedText('')
} }
// If the input is focused, the `Esc` callback will not be triggered here.
useHotkeys('esc', () => { useHotkeys('esc', () => {
if (route === 'home') { if (route === 'home') {
onCloseWindow() onCloseWindow()
@ -126,7 +178,7 @@ const HomeWindow: FC = () => {
}) })
useEffect(() => { 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 }) => { window.electron.ipcRenderer.on('selection-action', (_, { action, selectedText }) => {
selectedText && setSelectedText(selectedText) selectedText && setSelectedText(selectedText)
action && setRoute(action) action && setRoute(action)
@ -137,7 +189,7 @@ const HomeWindow: FC = () => {
window.electron.ipcRenderer.removeAllListeners('show-mini-window') window.electron.ipcRenderer.removeAllListeners('show-mini-window')
window.electron.ipcRenderer.removeAllListeners('selection-action') window.electron.ipcRenderer.removeAllListeners('selection-action')
} }
}, [onReadClipboard, onSendMessage, setRoute]) }, [onWindowShow, onSendMessage, setRoute])
// 当路由为home时初始化isFirstMessage为true // 当路由为home时初始化isFirstMessage为true
useEffect(() => { useEffect(() => {
@ -158,6 +210,7 @@ const HomeWindow: FC = () => {
placeholder={t('miniwindow.input.placeholder.empty', { model: model.name })} placeholder={t('miniwindow.input.placeholder.empty', { model: model.name })}
handleKeyDown={handleKeyDown} handleKeyDown={handleKeyDown}
handleChange={handleChange} handleChange={handleChange}
ref={inputBarRef}
/> />
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
</> </>
@ -197,11 +250,12 @@ const HomeWindow: FC = () => {
} }
handleKeyDown={handleKeyDown} handleKeyDown={handleKeyDown}
handleChange={handleChange} handleChange={handleChange}
ref={inputBarRef}
/> />
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} /> <ClipboardPreview referenceText={referenceText} clearClipboard={clearClipboard} t={t} />
<Main> <Main>
<FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} /> <FeatureMenus setRoute={setRoute} onSendMessage={onSendMessage} text={content} ref={featureMenusRef} />
</Main> </Main>
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<Footer <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 Scrollbar from '@renderer/components/Scrollbar'
import { Col } from 'antd' 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 { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
@ -11,7 +11,14 @@ interface FeatureMenusProps {
onSendMessage: (prompt?: string) => void 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 { t } = useTranslation()
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
@ -57,27 +64,20 @@ const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage })
[onSendMessage, setRoute, t, text] [onSendMessage, setRoute, t, text]
) )
useEffect(() => { useImperativeHandle(ref, () => ({
const handleKeyDown = (e: KeyboardEvent) => { nextFeature() {
switch (e.key) { setSelectedIndex((prev) => (prev < features.length - 1 ? prev + 1 : 0))
case 'ArrowUp': },
e.preventDefault() prevFeature() {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : features.length - 1)) setSelectedIndex((prev) => (prev > 0 ? prev - 1 : features.length - 1))
break },
case 'ArrowDown': useFeature() {
e.preventDefault() features[selectedIndex].onClick?.()
setSelectedIndex((prev) => (prev < features.length - 1 ? prev + 1 : 0)) },
break resetSelectedIndex() {
case 'Enter': setSelectedIndex(0)
e.preventDefault()
features[selectedIndex].onClick?.()
break
}
} }
}))
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [features, selectedIndex])
return ( return (
<FeatureList> <FeatureList>
@ -87,13 +87,15 @@ const FeatureMenus: FC<FeatureMenusProps> = ({ text, setRoute, onSendMessage })
<FeatureItem onClick={feature.onClick} className={index === selectedIndex ? 'active' : ''}> <FeatureItem onClick={feature.onClick} className={index === selectedIndex ? 'active' : ''}>
<FeatureIcon>{feature.icon}</FeatureIcon> <FeatureIcon>{feature.icon}</FeatureIcon>
<FeatureTitle>{feature.title}</FeatureTitle> <FeatureTitle>{feature.title}</FeatureTitle>
{index === selectedIndex && <EnterOutlined />}
</FeatureItem> </FeatureItem>
</Col> </Col>
))} ))}
</FeatureListWrapper> </FeatureListWrapper>
</FeatureList> </FeatureList>
) )
} })
FeatureMenus.displayName = 'FeatureMenus'
const FeatureList = styled(Scrollbar)` const FeatureList = styled(Scrollbar)`
flex: 1; flex: 1;
@ -109,7 +111,7 @@ const FeatureListWrapper = styled.div`
const FeatureItem = styled.div` const FeatureItem = styled.div`
cursor: pointer; cursor: pointer;
transition: all 0.3s; transition: background-color 0s;
background: transparent; background: transparent;
border: none; border: none;
padding: 8px 16px; padding: 8px 16px;
@ -121,11 +123,11 @@ const FeatureItem = styled.div`
user-select: none; user-select: none;
&:hover { &:hover {
background: var(--color-background-opacity); background: var(--color-background-mute);
} }
&.active { &.active {
background: var(--color-background-opacity); background: var(--color-background-mute);
} }
` `
@ -136,6 +138,7 @@ const FeatureIcon = styled.div`
const FeatureTitle = styled.h3` const FeatureTitle = styled.h3`
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
flex-basis: 100%;
` `
export default FeatureMenus export default FeatureMenus

View File

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