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

View File

@ -137,6 +137,7 @@ declare global {
hide: () => Promise<void>
close: () => Promise<void>
toggle: () => Promise<void>
setPin: (isPinned: boolean) => Promise<void>
}
aes: {
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'),
hide: () => ipcRenderer.invoke('miniwindow:hide'),
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: {
encrypt: (text: string, secretKey: string, iv: string) => ipcRenderer.invoke('aes:encrypt', text, secretKey, iv),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,10 @@ const ClipboardPreview: FC<ClipboardPreviewProps> = ({ referenceText, clearClipb
<Container>
<ClipboardContent>
<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')}
</Paragraph>
<CloseButton onClick={clearClipboard} className="nodrag">

View File

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

View File

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

View File

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