feat: miniWindow Pin/Resize (#3201)

feat: [#2030] miniWindow pin/resizable/copy toast/move optimized
This commit is contained in:
fullex 2025-04-02 10:26:56 +08:00 committed by GitHub
parent 91b9a48c48
commit 0fd9b6e56c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 125 additions and 27 deletions

View File

@ -255,6 +255,7 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow()) ipcMain.handle('miniwindow:hide', () => windowService.hideMiniWindow())
ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow()) ipcMain.handle('miniwindow:close', () => windowService.closeMiniWindow())
ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow()) ipcMain.handle('miniwindow:toggle', () => windowService.toggleMiniWindow())
ipcMain.handle('miniwindow:set-pin', (_, isPinned) => windowService.setPinMiniWindow(isPinned))
// aes // aes
ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv)) ipcMain.handle('aes:encrypt', (_, text: string, secretKey: string, iv: string) => encrypt(text, secretKey, iv))

View File

@ -15,6 +15,7 @@ export class WindowService {
private static instance: WindowService | null = null private static instance: WindowService | null = null
private mainWindow: BrowserWindow | null = null private mainWindow: BrowserWindow | null = null
private miniWindow: BrowserWindow | null = null private miniWindow: BrowserWindow | null = null
private isPinnedMiniWindow: boolean = false
private wasFullScreen: boolean = false private wasFullScreen: boolean = false
//hacky-fix: store the focused status of mainWindow before miniWindow shows //hacky-fix: store the focused status of mainWindow before miniWindow shows
//to restore the focus status when miniWindow hides //to restore the focus status when miniWindow hides
@ -378,8 +379,12 @@ export class WindowService {
public createMiniWindow(isPreload: boolean = false): BrowserWindow { public createMiniWindow(isPreload: boolean = false): BrowserWindow {
this.miniWindow = new BrowserWindow({ this.miniWindow = new BrowserWindow({
width: 500, width: 550,
height: 520, height: 400,
minWidth: 350,
minHeight: 380,
maxWidth: 1024,
maxHeight: 768,
show: false, show: false,
autoHideMenuBar: true, autoHideMenuBar: true,
transparent: isMac, transparent: isMac,
@ -388,7 +393,7 @@ export class WindowService {
center: true, center: true,
frame: false, frame: false,
alwaysOnTop: true, alwaysOnTop: true,
resizable: false, resizable: true,
useContentSize: true, useContentSize: true,
...(isMac ? { type: 'panel' } : {}), ...(isMac ? { type: 'panel' } : {}),
skipTaskbar: true, skipTaskbar: true,
@ -419,7 +424,9 @@ export class WindowService {
}) })
this.miniWindow.on('blur', () => { this.miniWindow.on('blur', () => {
this.hideMiniWindow() if (!this.isPinnedMiniWindow) {
this.hideMiniWindow()
}
}) })
this.miniWindow.on('closed', () => { this.miniWindow.on('closed', () => {
@ -503,6 +510,10 @@ export class WindowService {
this.showMiniWindow() this.showMiniWindow()
} }
public setPinMiniWindow(isPinned) {
this.isPinnedMiniWindow = isPinned
}
public showSelectionMenu(bounds: { x: number; y: number }) { public showSelectionMenu(bounds: { x: number; y: number }) {
if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) { if (this.selectionMenuWindow && !this.selectionMenuWindow.isDestroyed()) {
this.selectionMenuWindow.setPosition(bounds.x, bounds.y) this.selectionMenuWindow.setPosition(bounds.x, bounds.y)

View File

@ -137,6 +137,7 @@ declare global {
hide: () => Promise<void> hide: () => Promise<void>
close: () => Promise<void> close: () => Promise<void>
toggle: () => Promise<void> toggle: () => Promise<void>
setPin: (isPinned: boolean) => Promise<void>
} }
aes: { aes: {
encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }> encrypt: (text: string, secretKey: string, iv: string) => Promise<{ iv: string; encryptedData: string }>

View File

@ -112,7 +112,8 @@ const api = {
show: () => ipcRenderer.invoke('miniwindow:show'), show: () => ipcRenderer.invoke('miniwindow:show'),
hide: () => ipcRenderer.invoke('miniwindow:hide'), hide: () => ipcRenderer.invoke('miniwindow:hide'),
close: () => ipcRenderer.invoke('miniwindow:close'), close: () => ipcRenderer.invoke('miniwindow:close'),
toggle: () => ipcRenderer.invoke('miniwindow:toggle') toggle: () => ipcRenderer.invoke('miniwindow:toggle'),
setPin: (isPinned: boolean) => ipcRenderer.invoke('miniwindow:set-pin', isPinned)
}, },
aes: { aes: {
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv), encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),

View File

@ -580,15 +580,19 @@
}, },
"footer": { "footer": {
"copy_last_message": "Press C to copy", "copy_last_message": "Press C to copy",
"esc": "Press ESC {{action}}", "backspace_clear": "Backspace to clear",
"esc_back": "back", "esc": "ESC to {{action}}",
"esc_close": "close the window" "esc_back": "return",
"esc_close": "close"
}, },
"input": { "input": {
"placeholder": { "placeholder": {
"empty": "Ask {{model}} for help...", "empty": "Ask {{model}} for help...",
"title": "What do you want to do with this text?" "title": "What do you want to do with this text?"
} }
},
"tooltip": {
"pin": "Keep Window on Top"
} }
}, },
"models": { "models": {

View File

@ -582,13 +582,17 @@
"copy_last_message": "C キーを押してコピー", "copy_last_message": "C キーを押してコピー",
"esc": "ESC キーを押して{{action}}", "esc": "ESC キーを押して{{action}}",
"esc_back": "戻る", "esc_back": "戻る",
"esc_close": "ウィンドウを閉じる" "esc_close": "ウィンドウを閉じる",
"backspace_clear": "バックスペースを押してクリアします"
}, },
"input": { "input": {
"placeholder": { "placeholder": {
"empty": "{{model}} に質問してください...", "empty": "{{model}} に質問してください...",
"title": "下のテキストに対して何をしますか?" "title": "下のテキストに対して何をしますか?"
} }
},
"tooltip": {
"pin": "上部ウィンドウ"
} }
}, },
"models": { "models": {

View File

@ -582,13 +582,17 @@
"copy_last_message": "Нажмите C для копирования", "copy_last_message": "Нажмите C для копирования",
"esc": "Нажмите ESC {{action}}", "esc": "Нажмите ESC {{action}}",
"esc_back": "возвращения", "esc_back": "возвращения",
"esc_close": "закрытия окна" "esc_close": "закрытия окна",
"backspace_clear": "Нажмите Backspace, чтобы очистить"
}, },
"input": { "input": {
"placeholder": { "placeholder": {
"empty": "Задайте вопрос {{model}}...", "empty": "Задайте вопрос {{model}}...",
"title": "Что вы хотите сделать с этим текстом?" "title": "Что вы хотите сделать с этим текстом?"
} }
},
"tooltip": {
"pin": "Верхнее окно"
} }
}, },
"models": { "models": {

View File

@ -580,15 +580,19 @@
}, },
"footer": { "footer": {
"copy_last_message": "按 C 键复制", "copy_last_message": "按 C 键复制",
"backspace_clear": "按 Backspace 清空",
"esc": "按 ESC {{action}}", "esc": "按 ESC {{action}}",
"esc_back": "返回", "esc_back": "返回",
"esc_close": "关闭窗口" "esc_close": "关闭"
}, },
"input": { "input": {
"placeholder": { "placeholder": {
"empty": "询问 {{model}} 获取帮助...", "empty": "询问 {{model}} 获取帮助...",
"title": "你想对下方文字做什么" "title": "你想对下方文字做什么"
} }
},
"tooltip": {
"pin": "窗口置顶"
} }
}, },
"models": { "models": {

View File

@ -582,13 +582,17 @@
"copy_last_message": "按 C 鍵複製", "copy_last_message": "按 C 鍵複製",
"esc": "按 ESC {{action}}", "esc": "按 ESC {{action}}",
"esc_back": "返回", "esc_back": "返回",
"esc_close": "關閉視窗" "esc_close": "關閉視窗",
"backspace_clear": "按 Backspace 清空"
}, },
"input": { "input": {
"placeholder": { "placeholder": {
"empty": "詢問 {{model}} 取得幫助...", "empty": "詢問 {{model}} 取得幫助...",
"title": "你想對下方文字做什麼" "title": "你想對下方文字做什麼"
} }
},
"tooltip": {
"pin": "窗口置頂"
} }
}, },
"models": { "models": {

View File

@ -1,6 +1,7 @@
import '@renderer/databases' import '@renderer/databases'
import store, { persistor } from '@renderer/store' import store, { persistor } from '@renderer/store'
import { message } from 'antd'
import { Provider } from 'react-redux' import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react' import { PersistGate } from 'redux-persist/integration/react'
@ -10,12 +11,17 @@ import { ThemeProvider } from '../../context/ThemeProvider'
import HomeWindow from './home/HomeWindow' import HomeWindow from './home/HomeWindow'
function MiniWindow(): React.ReactElement { function MiniWindow(): React.ReactElement {
//miniWindow should register its own message component
const [messageApi, messageContextHolder] = message.useMessage()
window.message = messageApi
return ( return (
<Provider store={store}> <Provider store={store}>
<ThemeProvider> <ThemeProvider>
<AntdProvider> <AntdProvider>
<SyntaxHighlighterProvider> <SyntaxHighlighterProvider>
<PersistGate loading={null} persistor={persistor}> <PersistGate loading={null} persistor={persistor}>
{messageContextHolder}
<HomeWindow /> <HomeWindow />
</PersistGate> </PersistGate>
</SyntaxHighlighterProvider> </SyntaxHighlighterProvider>

View File

@ -38,7 +38,7 @@ const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetM
const messageBackground = getMessageBackground(true, isAssistantMessage) const messageBackground = getMessageBackground(true, isAssistantMessage)
const maxWidth = isMiniWindow() ? '480px' : '100%' const maxWidth = isMiniWindow() ? '800px' : '100%'
useEffect(() => { useEffect(() => {
if (onGetMessages && onSetMessages) { if (onGetMessages && onSetMessages) {
@ -93,6 +93,7 @@ const MessageItem: FC<Props> = ({ message: _message, index, total, route, onSetM
const MessageContainer = styled.div` const MessageContainer = styled.div`
display: flex; display: flex;
width: 100%;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
transition: background-color 0.3s ease; transition: background-color 0.3s ease;

View File

@ -77,6 +77,7 @@ const Messages: FC<Props> = ({ assistant, route }) => {
const Container = styled(Scrollbar)<ContainerProps>` const Container = styled(Scrollbar)<ContainerProps>`
display: flex; display: flex;
flex-direction: column-reverse; flex-direction: column-reverse;
align-items: center;
padding-bottom: 20px; padding-bottom: 20px;
overflow-x: hidden; overflow-x: hidden;
min-width: 100%; min-width: 100%;

View File

@ -278,6 +278,8 @@ const HomeWindow: FC = () => {
<Divider style={{ margin: '10px 0' }} /> <Divider style={{ margin: '10px 0' }} />
<Footer <Footer
route={route} route={route}
canUseBackspace={text.length > 0 || clipboardText.length == 0}
clearClipboard={clearClipboard}
onExit={() => { onExit={() => {
setRoute('home') setRoute('home')
setText('') setText('')
@ -292,6 +294,7 @@ const Container = styled.div`
display: flex; display: flex;
flex: 1; flex: 1;
height: 100%; height: 100%;
width: 100%;
flex-direction: column; flex-direction: column;
-webkit-app-region: drag; -webkit-app-region: drag;
padding: 8px 10px; padding: 8px 10px;
@ -299,6 +302,8 @@ const Container = styled.div`
const Main = styled.main` const Main = styled.main`
display: flex; display: flex;
flex-direction: column;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
` `

View File

@ -19,7 +19,10 @@ const ClipboardPreview: FC<ClipboardPreviewProps> = ({ referenceText, clearClipb
<Container> <Container>
<ClipboardContent> <ClipboardContent>
<CopyIcon style={{ fontSize: '14px', flexShrink: 0, cursor: 'pointer' }} className="nodrag" /> <CopyIcon style={{ fontSize: '14px', flexShrink: 0, cursor: 'pointer' }} className="nodrag" />
<Paragraph ellipsis={{ rows: 2 }} style={{ margin: '0 12px', fontSize: 12, flex: 1, minWidth: 0 }}> <Paragraph
ellipsis={{ rows: 2 }}
style={{ margin: '0 12px', fontSize: 12, flex: 1, minWidth: 0 }}
className="nodrag">
{referenceText || t('miniwindow.clipboard.empty')} {referenceText || t('miniwindow.clipboard.empty')}
</Paragraph> </Paragraph>
<CloseButton onClick={clearClipboard} className="nodrag"> <CloseButton onClick={clearClipboard} className="nodrag">

View File

@ -103,7 +103,8 @@ const FeatureMenus = ({
FeatureMenus.displayName = 'FeatureMenus' FeatureMenus.displayName = 'FeatureMenus'
const FeatureList = styled(Scrollbar)` const FeatureList = styled(Scrollbar)`
flex: 1; flex-shrink: 0;
height: auto;
-webkit-app-region: none; -webkit-app-region: none;
` `

View File

@ -1,27 +1,60 @@
import { CopyOutlined, LoginOutlined } from '@ant-design/icons' import { ArrowLeftOutlined, CopyOutlined, LogoutOutlined, PushpinFilled, PushpinOutlined } from '@ant-design/icons'
import { Tag } from 'antd' import { Tag, Tooltip } from 'antd'
import { FC } from 'react' import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface FooterProps { interface FooterProps {
route: string route: string
canUseBackspace?: boolean
clearClipboard?: () => void
onExit: () => void onExit: () => void
} }
const Footer: FC<FooterProps> = ({ route, onExit }) => { const Footer: FC<FooterProps> = ({ route, canUseBackspace, clearClipboard, onExit }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [isPinned, setIsPinned] = useState(false)
const onClickPin = () => {
window.api.miniWindow.setPin(!isPinned).then(() => {
setIsPinned(!isPinned)
})
}
return ( return (
<WindowFooter> <WindowFooter className="drag">
<FooterText className="nodrag"> <PinButtonArea onClick={() => onClickPin()} className="nodrag">
<Tag bordered={false} icon={<LoginOutlined />} onClick={() => onExit()}> <Tooltip title={t('miniwindow.tooltip.pin')} mouseEnterDelay={0.8} placement="left">
{isPinned ? (
<PushpinFilled style={{ fontSize: '18px', color: 'var(--color-primary)' }} />
) : (
<PushpinOutlined style={{ fontSize: '18px' }} />
)}
</Tooltip>
</PinButtonArea>
<FooterText>
<Tag
bordered={false}
icon={<LogoutOutlined />}
style={{ cursor: 'pointer' }}
className="nodrag"
onClick={() => onExit()}>
{t('miniwindow.footer.esc', { {t('miniwindow.footer.esc', {
action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back') action: route === 'home' ? t('miniwindow.footer.esc_close') : t('miniwindow.footer.esc_back')
})} })}
</Tag> </Tag>
{route === 'home' && !canUseBackspace && (
<Tag
bordered={false}
icon={<ArrowLeftOutlined />}
style={{ cursor: 'pointer' }}
className="nodrag"
onClick={() => clearClipboard!()}>
{t('miniwindow.footer.backspace_clear')}
</Tag>
)}
{route !== 'home' && ( {route !== 'home' && (
<Tag bordered={false} icon={<CopyOutlined />}> <Tag bordered={false} icon={<CopyOutlined />} style={{ cursor: 'pointer' }} className="nodrag">
{t('miniwindow.footer.copy_last_message')} {t('miniwindow.footer.copy_last_message')}
</Tag> </Tag>
)} )}
@ -31,14 +64,17 @@ const Footer: FC<FooterProps> = ({ route, onExit }) => {
} }
const WindowFooter = styled.div` const WindowFooter = styled.div`
position: relative;
width: 100%;
text-align: center; text-align: center;
padding: 5px 0; padding: 5px 0;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-size: 12px; font-size: 12px;
cursor: pointer;
` `
const FooterText = styled.div` const FooterText = styled.div`
width: 100%;
height: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -47,4 +83,12 @@ const FooterText = styled.div`
font-size: 12px; font-size: 12px;
` `
const PinButtonArea = styled.div`
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
`
export default Footer export default Footer

View File

@ -74,6 +74,7 @@ const Translate: FC<Props> = ({ text }) => {
useHotkeys('c', () => { useHotkeys('c', () => {
navigator.clipboard.writeText(result) navigator.clipboard.writeText(result)
window.message.success(t('message.copy.success'))
}) })
return ( return (
@ -82,7 +83,7 @@ const Translate: FC<Props> = ({ text }) => {
<Select <Select
showSearch showSearch
value="any" value="any"
style={{ width: 200 }} style={{ maxWidth: 200, minWidth: 100, flex: 1 }}
optionFilterProp="label" optionFilterProp="label"
disabled disabled
options={[{ label: t('translate.any.language'), value: 'any' }]} options={[{ label: t('translate.any.language'), value: 'any' }]}
@ -91,7 +92,7 @@ const Translate: FC<Props> = ({ text }) => {
<Select <Select
showSearch showSearch
value={targetLanguage} value={targetLanguage}
style={{ width: 200 }} style={{ maxWidth: 200, minWidth: 130, flex: 1 }}
optionFilterProp="label" optionFilterProp="label"
options={TranslateLanguageOptions} options={TranslateLanguageOptions}
onChange={async (value) => { onChange={async (value) => {
@ -126,7 +127,7 @@ const Container = styled.div`
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
padding: 12px; padding: 12px;
padding-right: 0; /* padding-right: 0; */
overflow: hidden; overflow: hidden;
-webkit-app-region: none; -webkit-app-region: none;
` `
@ -151,8 +152,10 @@ const LoadingText = styled.div`
const MenuContainer = styled.div` const MenuContainer = styled.div`
display: flex; display: flex;
width: 100%;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: center;
margin-bottom: 15px; margin-bottom: 15px;
gap: 20px; gap: 20px;
` `