feat: MinApp tabs on sidebar, we can keep MinApps alive and re-open it without loading again.
This commit is contained in:
parent
433d562599
commit
57ba91072d
369
src/renderer/src/components/MinApp/MinappPopupContainer.tsx
Normal file
369
src/renderer/src/components/MinApp/MinappPopupContainer.tsx
Normal file
@ -0,0 +1,369 @@
|
||||
import {
|
||||
CloseOutlined,
|
||||
CodeOutlined,
|
||||
ExportOutlined,
|
||||
MinusOutlined,
|
||||
PushpinOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { Avatar, Drawer, Tooltip } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import WebviewContainer from './WebviewContainer'
|
||||
|
||||
interface AppExtraInfo {
|
||||
canPinned: boolean
|
||||
isPinned: boolean
|
||||
canOpenExternalLink: boolean
|
||||
}
|
||||
|
||||
type AppInfo = MinAppType & AppExtraInfo
|
||||
|
||||
/** The main container for MinApp popup */
|
||||
const MinappPopupContainer: React.FC = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp, currentMinappId, minappShow } = useRuntime()
|
||||
const { closeMinapp, hideMinappPopup } = useMinappPopup()
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
|
||||
/** control the drawer open or close */
|
||||
const [isPopupShow, setIsPopupShow] = useState(true)
|
||||
/** whether the current minapp is ready */
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
|
||||
/** store the last minapp id and show status */
|
||||
const lastMinappId = useRef<string | null>(null)
|
||||
const lastMinappShow = useRef<boolean>(false)
|
||||
|
||||
/** store the webview refs, one of the key to make them keepalive */
|
||||
const webviewRefs = useRef<Map<string, WebviewTag | null>>(new Map())
|
||||
/** indicate whether the webview has loaded */
|
||||
const webviewLoadedRefs = useRef<Map<string, boolean>>(new Map())
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
useBridge()
|
||||
|
||||
/** set the popup display status */
|
||||
useEffect(() => {
|
||||
if (minappShow) {
|
||||
setIsPopupShow(true)
|
||||
|
||||
if (webviewLoadedRefs.current.get(currentMinappId)) {
|
||||
setIsReady(true)
|
||||
/** the case that open the minapp from sidebar */
|
||||
} else if (lastMinappId.current !== currentMinappId && lastMinappShow.current === minappShow) {
|
||||
setIsReady(false)
|
||||
}
|
||||
} else {
|
||||
setIsPopupShow(false)
|
||||
setIsReady(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
/** renew the last minapp id and show status */
|
||||
lastMinappId.current = currentMinappId
|
||||
lastMinappShow.current = minappShow
|
||||
}
|
||||
}, [minappShow, currentMinappId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewRefs.current) return
|
||||
|
||||
/** set the webview display status
|
||||
* DO NOT use the state to set the display status,
|
||||
* to AVOID the re-render of the webview container
|
||||
*/
|
||||
webviewRefs.current.forEach((webviewRef, appid) => {
|
||||
if (!webviewRef) return
|
||||
webviewRef.style.display = appid === currentMinappId ? 'inline-flex' : 'none'
|
||||
})
|
||||
|
||||
//delete the extra webviewLoadedRefs
|
||||
webviewLoadedRefs.current.forEach((_, appid) => {
|
||||
if (!webviewRefs.current.has(appid)) {
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
}
|
||||
})
|
||||
}, [currentMinappId])
|
||||
|
||||
/** combine the openedKeepAliveMinapps and openedOneOffMinapp */
|
||||
const combinedApps = useMemo(() => {
|
||||
return [...openedKeepAliveMinapps, ...(openedOneOffMinapp ? [openedOneOffMinapp] : [])]
|
||||
}, [openedKeepAliveMinapps, openedOneOffMinapp])
|
||||
|
||||
/** get the extra info of the apps */
|
||||
const appsExtraInfo = useMemo(() => {
|
||||
return combinedApps.reduce(
|
||||
(acc, app) => ({
|
||||
...acc,
|
||||
[app.id]: {
|
||||
canPinned: DEFAULT_MIN_APPS.some((item) => item.id === app.id),
|
||||
isPinned: pinned.some((item) => item.id === app.id),
|
||||
canOpenExternalLink: app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||
}
|
||||
}),
|
||||
{} as Record<string, AppExtraInfo>
|
||||
)
|
||||
}, [combinedApps, pinned])
|
||||
|
||||
/** get the current app info with extra info */
|
||||
let currentAppInfo: AppInfo | null = null
|
||||
if (currentMinappId) {
|
||||
const currentApp = combinedApps.find((item) => item.id === currentMinappId) as MinAppType
|
||||
currentAppInfo = { ...currentApp, ...appsExtraInfo[currentApp.id] }
|
||||
}
|
||||
|
||||
/** will close the popup and delete the webview */
|
||||
const handlePopupClose = async (appid: string) => {
|
||||
setIsPopupShow(false)
|
||||
await delay(0.3)
|
||||
webviewLoadedRefs.current.delete(appid)
|
||||
closeMinapp(appid)
|
||||
}
|
||||
|
||||
/** will hide the popup and remain the webviews */
|
||||
const handlePopupMinimize = async () => {
|
||||
setIsPopupShow(false)
|
||||
await delay(0.3)
|
||||
hideMinappPopup()
|
||||
}
|
||||
|
||||
/** the callback function to set the webviews ref */
|
||||
const handleWebviewSetRef = (appid: string, element: WebviewTag | null) => {
|
||||
webviewRefs.current.set(appid, element)
|
||||
|
||||
if (!webviewRefs.current.has(appid)) {
|
||||
webviewRefs.current.set(appid, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (element) {
|
||||
webviewRefs.current.set(appid, element)
|
||||
} else {
|
||||
webviewRefs.current.delete(appid)
|
||||
}
|
||||
}
|
||||
|
||||
/** the callback function to set the webviews loaded indicator */
|
||||
const handleWebviewLoaded = (appid: string) => {
|
||||
webviewLoadedRefs.current.set(appid, true)
|
||||
if (appid == currentMinappId) {
|
||||
setTimeout(() => setIsReady(true), 200)
|
||||
}
|
||||
}
|
||||
|
||||
/** will open the devtools of the minapp */
|
||||
const handleOpenDevTools = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
webview.openDevTools()
|
||||
}
|
||||
}
|
||||
|
||||
/** only reload the original url */
|
||||
const handleReload = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
const url = combinedApps.find((item) => item.id === appid)?.url
|
||||
if (url) {
|
||||
webview.src = url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** only open the current url */
|
||||
const handleOpenLink = (appid: string) => {
|
||||
const webview = webviewRefs.current.get(appid)
|
||||
if (webview) {
|
||||
window.api.openWebsite(webview.getURL())
|
||||
}
|
||||
}
|
||||
|
||||
/** toggle the pin status of the minapp */
|
||||
const handleTogglePin = (appid: string) => {
|
||||
const app = combinedApps.find((item) => item.id === appid)
|
||||
if (!app) return
|
||||
|
||||
const newPinned = appsExtraInfo[appid].isPinned ? pinned.filter((item) => item.id !== appid) : [...pinned, app]
|
||||
updatePinnedMinapps(newPinned)
|
||||
}
|
||||
|
||||
/** Title bar of the popup */
|
||||
const Title = ({ appInfo }: { appInfo: AppInfo | null }) => {
|
||||
if (!appInfo) return null
|
||||
return (
|
||||
<TitleContainer style={{ justifyContent: 'space-between' }}>
|
||||
<TitleText>{appInfo.name}</TitleText>
|
||||
<ButtonsGroup className={isWindows ? 'windows' : ''}>
|
||||
<Tooltip title={t('minapp.popup.refresh')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleReload(appInfo.id)}>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{appInfo.canPinned && (
|
||||
<Tooltip
|
||||
title={appInfo.isPinned ? t('minapp.sidebar.remove.title') : t('minapp.sidebar.add.title')}
|
||||
mouseEnterDelay={0.8}
|
||||
placement="bottom">
|
||||
<Button onClick={() => handleTogglePin(appInfo.id)} className={appInfo.isPinned ? 'pinned' : ''}>
|
||||
<PushpinOutlined style={{ fontSize: 16 }} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{appInfo.canOpenExternalLink && (
|
||||
<Tooltip title={t('minapp.popup.openExternal')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenLink(appInfo.id)}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isInDevelopment && (
|
||||
<Tooltip title={t('minapp.popup.devtools')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handleOpenDevTools(appInfo.id)}>
|
||||
<CodeOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title={t('minapp.popup.minimize')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handlePopupMinimize()}>
|
||||
<MinusOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('minapp.popup.close')} mouseEnterDelay={0.8} placement="bottom">
|
||||
<Button onClick={() => handlePopupClose(appInfo.id)}>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</ButtonsGroup>
|
||||
</TitleContainer>
|
||||
)
|
||||
}
|
||||
|
||||
/** group the webview containers with Memo, one of the key to make them keepalive */
|
||||
const WebviewContainerGroup = useMemo(() => {
|
||||
return combinedApps.map((app) => (
|
||||
<WebviewContainer
|
||||
key={app.id}
|
||||
appid={app.id}
|
||||
url={app.url}
|
||||
onSetRefCallback={handleWebviewSetRef}
|
||||
onLoadedCallback={handleWebviewLoaded}
|
||||
/>
|
||||
))
|
||||
|
||||
// because the combinedApps is enough
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [combinedApps])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<Title appInfo={currentAppInfo} />}
|
||||
placement="bottom"
|
||||
onClose={handlePopupMinimize}
|
||||
open={isPopupShow}
|
||||
destroyOnClose={false}
|
||||
mask={false}
|
||||
rootClassName="minapp-drawer"
|
||||
maskClassName="minapp-mask"
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{ marginLeft: 'var(--sidebar-width)' }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar
|
||||
src={currentAppInfo?.logo}
|
||||
size={80}
|
||||
style={{ border: '1px solid var(--color-border)', marginTop: -150 }}
|
||||
/>
|
||||
<BeatLoader color="var(--color-text-2)" size="10px" style={{ marginTop: 15 }} />
|
||||
</EmptyView>
|
||||
)}
|
||||
{WebviewContainerGroup}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${isMac ? '20px' : '10px'};
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: transparent;
|
||||
`
|
||||
|
||||
const TitleText = styled.div`
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-right: 10px;
|
||||
user-select: none;
|
||||
`
|
||||
const ButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
-webkit-app-region: no-drag;
|
||||
&.windows {
|
||||
margin-right: ${isWindows ? '130px' : 0};
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 50px;
|
||||
padding: 0 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled.div`
|
||||
cursor: pointer;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-2);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&.pinned {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-bg);
|
||||
}
|
||||
`
|
||||
|
||||
const EmptyView = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
export default MinappPopupContainer
|
||||
@ -0,0 +1,11 @@
|
||||
import MinappPopupContainer from '@renderer/components/MinApp/MinappPopupContainer'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
|
||||
const TopViewMinappContainer = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp } = useRuntime()
|
||||
const isCreate = openedKeepAliveMinapps.length > 0 || openedOneOffMinapp !== null
|
||||
|
||||
return <>{isCreate && <MinappPopupContainer />}</>
|
||||
}
|
||||
|
||||
export default TopViewMinappContainer
|
||||
84
src/renderer/src/components/MinApp/WebviewContainer.tsx
Normal file
84
src/renderer/src/components/MinApp/WebviewContainer.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { WebviewTag } from 'electron'
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* WebviewContainer is a component that renders a webview element.
|
||||
* It is used in the MinAppPopupContainer component.
|
||||
* The webcontent can be remain in memory
|
||||
*/
|
||||
const WebviewContainer = memo(
|
||||
({
|
||||
appid,
|
||||
url,
|
||||
onSetRefCallback,
|
||||
onLoadedCallback
|
||||
}: {
|
||||
appid: string
|
||||
url: string
|
||||
onSetRefCallback: (appid: string, element: WebviewTag | null) => void
|
||||
onLoadedCallback: (appid: string) => void
|
||||
}) => {
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
|
||||
const setRef = (appid: string) => {
|
||||
onSetRefCallback(appid, null)
|
||||
|
||||
return (element: WebviewTag | null) => {
|
||||
onSetRefCallback(appid, element)
|
||||
if (element) {
|
||||
webviewRef.current = element
|
||||
} else {
|
||||
webviewRef.current = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!webviewRef.current) return
|
||||
|
||||
const handleNewWindow = (event: any) => {
|
||||
event.preventDefault()
|
||||
if (webviewRef.current?.loadURL) {
|
||||
webviewRef.current.loadURL(event.url)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoaded = () => {
|
||||
onLoadedCallback(appid)
|
||||
}
|
||||
|
||||
webviewRef.current.addEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current.addEventListener('did-finish-load', handleLoaded)
|
||||
|
||||
// we set the url when the webview is ready
|
||||
webviewRef.current.src = url
|
||||
|
||||
return () => {
|
||||
webviewRef.current?.removeEventListener('new-window', handleNewWindow)
|
||||
webviewRef.current?.removeEventListener('did-finish-load', handleLoaded)
|
||||
}
|
||||
// because the appid and url are enough, no need to add onLoadedCallback
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appid, url])
|
||||
|
||||
return (
|
||||
<webview
|
||||
key={appid}
|
||||
ref={setRef(appid)}
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition={`persist:webview-${appid}`}
|
||||
nodeintegration={'true' as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'white',
|
||||
display: 'inline-flex'
|
||||
}
|
||||
|
||||
export default WebviewContainer
|
||||
@ -1,282 +0,0 @@
|
||||
import { CloseOutlined, CodeOutlined, ExportOutlined, PushpinOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { isMac, isWindows } from '@renderer/config/constant'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { DEFAULT_MIN_APPS } from '@renderer/config/minapps'
|
||||
import { useBridge } from '@renderer/hooks/useBridge'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import store from '@renderer/store'
|
||||
import { setMinappShow } from '@renderer/store/runtime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import { delay } from '@renderer/utils'
|
||||
import { Avatar, Drawer } from 'antd'
|
||||
import { WebviewTag } from 'electron'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { TopView } from '../TopView'
|
||||
|
||||
interface Props {
|
||||
app: MinAppType
|
||||
resolve: (data: any) => void
|
||||
}
|
||||
|
||||
const PopupContainer: React.FC<Props> = ({ app, resolve }) => {
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const [open, setOpen] = useState(true)
|
||||
const [opened, setOpened] = useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const webviewRef = useRef<WebviewTag | null>(null)
|
||||
|
||||
useBridge()
|
||||
|
||||
const canOpenExternalLink = app.url.startsWith('http://') || app.url.startsWith('https://')
|
||||
const canPinned = DEFAULT_MIN_APPS.some((i) => i.id === app?.id)
|
||||
|
||||
const onClose = async (_delay = 0.3) => {
|
||||
setOpen(false)
|
||||
await delay(_delay)
|
||||
resolve({})
|
||||
}
|
||||
|
||||
MinApp.onClose = onClose
|
||||
const openDevTools = () => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.openDevTools()
|
||||
}
|
||||
}
|
||||
const onReload = () => {
|
||||
if (webviewRef.current) {
|
||||
webviewRef.current.src = app.url
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenLink = () => {
|
||||
if (webviewRef.current) {
|
||||
const currentUrl = webviewRef.current.getURL()
|
||||
window.api.openWebsite(currentUrl)
|
||||
}
|
||||
}
|
||||
|
||||
const onTogglePin = () => {
|
||||
const newPinned = isPinned ? pinned.filter((item) => item.id !== app.id) : [...pinned, app]
|
||||
updatePinnedMinapps(newPinned)
|
||||
}
|
||||
|
||||
const isInDevelopment = process.env.NODE_ENV === 'development'
|
||||
|
||||
const Title = () => {
|
||||
return (
|
||||
<TitleContainer style={{ justifyContent: 'space-between' }}>
|
||||
<TitleText>{app.name}</TitleText>
|
||||
<ButtonsGroup className={isWindows ? 'windows' : ''}>
|
||||
<Button onClick={onReload}>
|
||||
<ReloadOutlined />
|
||||
</Button>
|
||||
{canPinned && (
|
||||
<Button onClick={onTogglePin} className={isPinned ? 'pinned' : ''}>
|
||||
<PushpinOutlined style={{ fontSize: 16 }} />
|
||||
</Button>
|
||||
)}
|
||||
{canOpenExternalLink && (
|
||||
<Button onClick={onOpenLink}>
|
||||
<ExportOutlined />
|
||||
</Button>
|
||||
)}
|
||||
{isInDevelopment && (
|
||||
<Button onClick={openDevTools}>
|
||||
<CodeOutlined />
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onClose()}>
|
||||
<CloseOutlined />
|
||||
</Button>
|
||||
</ButtonsGroup>
|
||||
</TitleContainer>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const webview = webviewRef.current
|
||||
|
||||
if (webview) {
|
||||
const handleNewWindow = (event: any) => {
|
||||
event.preventDefault()
|
||||
if (webview.loadURL) {
|
||||
webview.loadURL(event.url)
|
||||
}
|
||||
}
|
||||
|
||||
const onLoaded = () => setIsReady(true)
|
||||
|
||||
webview.addEventListener('new-window', handleNewWindow)
|
||||
webview.addEventListener('did-finish-load', onLoaded)
|
||||
|
||||
return () => {
|
||||
webview.removeEventListener('new-window', handleNewWindow)
|
||||
webview.removeEventListener('did-finish-load', onLoaded)
|
||||
}
|
||||
}
|
||||
|
||||
return () => {}
|
||||
}, [opened])
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setOpened(true), 350)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<Title />}
|
||||
placement="bottom"
|
||||
onClose={() => onClose()}
|
||||
open={open}
|
||||
mask={true}
|
||||
rootClassName="minapp-drawer"
|
||||
maskClassName="minapp-mask"
|
||||
height={'100%'}
|
||||
maskClosable={false}
|
||||
closeIcon={null}
|
||||
style={{ marginLeft: 'var(--sidebar-width)' }}>
|
||||
{!isReady && (
|
||||
<EmptyView>
|
||||
<Avatar src={app.logo} size={80} style={{ border: '1px solid var(--color-border)', marginTop: -150 }} />
|
||||
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginTop: 15 }} />
|
||||
</EmptyView>
|
||||
)}
|
||||
{opened && (
|
||||
<webview
|
||||
src={app.url}
|
||||
ref={webviewRef}
|
||||
style={WebviewStyle}
|
||||
allowpopups={'true' as any}
|
||||
partition="persist:webview"
|
||||
nodeintegration={true}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const WebviewStyle: React.CSSProperties = {
|
||||
width: 'calc(100vw - var(--sidebar-width))',
|
||||
height: 'calc(100vh - var(--navbar-height))',
|
||||
backgroundColor: 'white',
|
||||
display: 'inline-flex'
|
||||
}
|
||||
|
||||
const TitleContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${isMac ? '20px' : '10px'};
|
||||
padding-right: 10px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: transparent;
|
||||
`
|
||||
|
||||
const TitleText = styled.div`
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-1);
|
||||
margin-right: 10px;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
const ButtonsGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
-webkit-app-region: no-drag;
|
||||
&.windows {
|
||||
margin-right: ${isWindows ? '130px' : 0};
|
||||
background-color: var(--color-background-mute);
|
||||
border-radius: 50px;
|
||||
padding: 0 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
`
|
||||
|
||||
const Button = styled.div`
|
||||
cursor: pointer;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color-text-2);
|
||||
transition: all 0.2s ease;
|
||||
font-size: 14px;
|
||||
&:hover {
|
||||
color: var(--color-text-1);
|
||||
background-color: var(--color-background-mute);
|
||||
}
|
||||
&.pinned {
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-primary-bg);
|
||||
}
|
||||
`
|
||||
|
||||
const EmptyView = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-background);
|
||||
`
|
||||
|
||||
export default class MinApp {
|
||||
static topviewId = 0
|
||||
static onClose = () => {}
|
||||
static app: MinAppType | null = null
|
||||
|
||||
static async start(app: MinAppType) {
|
||||
if (app?.id && MinApp.app?.id === app?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (MinApp.app) {
|
||||
// @ts-ignore delay params
|
||||
await MinApp.onClose(0)
|
||||
await delay(0)
|
||||
}
|
||||
|
||||
if (!app.logo) {
|
||||
app.logo = AppLogo
|
||||
}
|
||||
|
||||
MinApp.app = app
|
||||
store.dispatch(setMinappShow(true))
|
||||
|
||||
return new Promise<any>((resolve) => {
|
||||
TopView.show(
|
||||
<PopupContainer
|
||||
app={app}
|
||||
resolve={(v) => {
|
||||
resolve(v)
|
||||
this.close()
|
||||
}}
|
||||
/>,
|
||||
'MinApp'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
static close() {
|
||||
TopView.hide('MinApp')
|
||||
store.dispatch(setMinappShow(false))
|
||||
MinApp.app = null
|
||||
MinApp.onClose = () => {}
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import TopViewMinappContainer from '@renderer/components/MinApp/TopViewMinappContainer'
|
||||
import { useAppInit } from '@renderer/hooks/useAppInit'
|
||||
import { message, Modal } from 'antd'
|
||||
import React, { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
|
||||
@ -76,6 +77,7 @@ const TopViewContainer: React.FC<Props> = ({ children }) => {
|
||||
{children}
|
||||
{messageContextHolder}
|
||||
{modalContextHolder}
|
||||
<TopViewMinappContainer />
|
||||
{elements.map(({ element: Element, id }) => (
|
||||
<FullScreenContainer key={`TOPVIEW_${id}`}>
|
||||
{typeof Element === 'function' ? <Element /> : Element}
|
||||
|
||||
@ -9,35 +9,36 @@ import { isMac } from '@renderer/config/constant'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import useNavBackgroundColor from '@renderer/hooks/useNavBackgroundColor'
|
||||
import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { isEmoji } from '@renderer/utils'
|
||||
import type { MenuProps } from 'antd'
|
||||
import { Tooltip } from 'antd'
|
||||
import { Avatar } from 'antd'
|
||||
import { Dropdown } from 'antd'
|
||||
import { FC } from 'react'
|
||||
import { Avatar, Dropdown, Tooltip } from 'antd'
|
||||
import { FC, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import DragableList from '../DragableList'
|
||||
import MinAppIcon from '../Icons/MinAppIcon'
|
||||
import MinApp from '../MinApp'
|
||||
import UserPopup from '../Popups/UserPopup'
|
||||
|
||||
const Sidebar: FC = () => {
|
||||
const { pathname } = useLocation()
|
||||
const avatar = useAvatar()
|
||||
const { minappShow } = useRuntime()
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { hideMinappPopup, openMinapp } = useMinappPopup()
|
||||
const { minappShow, currentMinappId } = useRuntime()
|
||||
const { sidebarIcons } = useSettings()
|
||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||
const { pinned } = useMinapps()
|
||||
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const { theme, settingTheme, toggleTheme } = useTheme()
|
||||
const avatar = useAvatar()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const onEditUser = () => UserPopup.show()
|
||||
|
||||
const backgroundColor = useNavBackgroundColor()
|
||||
@ -49,9 +50,10 @@ const Sidebar: FC = () => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
const docsId = 'cherrystudio-docs'
|
||||
const onOpenDocs = () => {
|
||||
MinApp.start({
|
||||
id: 'docs',
|
||||
openMinapp({
|
||||
id: docsId,
|
||||
name: t('docs.title'),
|
||||
url: 'https://docs.cherry-ai.com/',
|
||||
logo: AppLogo
|
||||
@ -66,9 +68,10 @@ const Sidebar: FC = () => {
|
||||
<AvatarImg src={avatar || UserAvatar} draggable={false} className="nodrag" onClick={onEditUser} />
|
||||
)}
|
||||
<MainMenusContainer>
|
||||
<Menus onClick={MinApp.onClose}>
|
||||
<Menus onClick={hideMinappPopup}>
|
||||
<MainMenus />
|
||||
</Menus>
|
||||
<SidebarOpenedMinappTabs />
|
||||
{showPinnedApps && (
|
||||
<AppsContainer>
|
||||
<Divider />
|
||||
@ -80,10 +83,7 @@ const Sidebar: FC = () => {
|
||||
</MainMenusContainer>
|
||||
<Menus>
|
||||
<Tooltip title={t('docs.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<Icon
|
||||
theme={theme}
|
||||
onClick={onOpenDocs}
|
||||
className={minappShow && MinApp.app?.url === 'https://docs.cherry-ai.com/' ? 'active' : ''}>
|
||||
<Icon theme={theme} onClick={onOpenDocs} className={minappShow && currentMinappId === docsId ? 'active' : ''}>
|
||||
<QuestionCircleOutlined />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
@ -102,7 +102,7 @@ const Sidebar: FC = () => {
|
||||
<Tooltip title={t('settings.title')} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink
|
||||
onClick={async () => {
|
||||
minappShow && (await MinApp.close())
|
||||
hideMinappPopup()
|
||||
await modelGenerating()
|
||||
await to('/settings/provider')
|
||||
}}>
|
||||
@ -117,6 +117,7 @@ const Sidebar: FC = () => {
|
||||
}
|
||||
|
||||
const MainMenus: FC = () => {
|
||||
const { hideMinappPopup } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { pathname } = useLocation()
|
||||
const { sidebarIcons } = useSettings()
|
||||
@ -155,7 +156,7 @@ const MainMenus: FC = () => {
|
||||
<Tooltip key={icon} title={t(`${icon}.title`)} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink
|
||||
onClick={async () => {
|
||||
minappShow && (await MinApp.close())
|
||||
hideMinappPopup()
|
||||
await modelGenerating()
|
||||
navigate(path)
|
||||
}}>
|
||||
@ -168,11 +169,99 @@ const MainMenus: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
/** Tabs of opened minapps in sidebar */
|
||||
const SidebarOpenedMinappTabs: FC = () => {
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { openMinappKeepAlive, hideMinappPopup, closeMinapp, closeAllMinapps } = useMinappPopup()
|
||||
const { theme } = useTheme()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleOnClick = (app) => {
|
||||
if (minappShow && currentMinappId === app.id) {
|
||||
hideMinappPopup()
|
||||
} else {
|
||||
openMinappKeepAlive(app)
|
||||
}
|
||||
}
|
||||
|
||||
// animation for minapp switch indicator
|
||||
useEffect(() => {
|
||||
//hacky way to get the height of the icon
|
||||
const iconDefaultHeight = 40
|
||||
const iconDefaultOffset = 17
|
||||
const container = document.querySelector('.TabsContainer') as HTMLElement
|
||||
const activeIcon = document.querySelector('.TabsContainer .opened-active') as HTMLElement
|
||||
|
||||
let indicatorTop = 0,
|
||||
indicatorRight = 0
|
||||
if (minappShow && activeIcon && container) {
|
||||
indicatorTop = activeIcon.offsetTop + activeIcon.offsetHeight / 2 - 4 // 4 is half of the indicator's height (8px)
|
||||
indicatorRight = 0
|
||||
} else {
|
||||
indicatorTop =
|
||||
((openedKeepAliveMinapps.length > 0 ? openedKeepAliveMinapps.length : 1) / 2) * iconDefaultHeight +
|
||||
iconDefaultOffset -
|
||||
4
|
||||
indicatorRight = -50
|
||||
}
|
||||
container.style.setProperty('--indicator-top', `${indicatorTop}px`)
|
||||
container.style.setProperty('--indicator-right', `${indicatorRight}px`)
|
||||
}, [currentMinappId, openedKeepAliveMinapps, minappShow])
|
||||
|
||||
const isShowOpened = openedKeepAliveMinapps.length > 0
|
||||
if (!isShowOpened) return <TabsContainer className="TabsContainer" />
|
||||
|
||||
return (
|
||||
<TabsContainer className="TabsContainer">
|
||||
<Divider />
|
||||
<TabsWrapper>
|
||||
<Menus>
|
||||
{openedKeepAliveMinapps.map((app) => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'closeApp',
|
||||
label: t('minapp.sidebar.close.title'),
|
||||
onClick: () => {
|
||||
closeMinapp(app.id)
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'closeAllApp',
|
||||
label: t('minapp.sidebar.closeall.title'),
|
||||
onClick: () => {
|
||||
closeAllMinapps()
|
||||
}
|
||||
}
|
||||
]
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
|
||||
return (
|
||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||
<Icon
|
||||
theme={theme}
|
||||
onClick={() => handleOnClick(app)}
|
||||
className={`${isActive ? 'opened-active' : ''}`}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
</StyledLink>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</Menus>
|
||||
</TabsWrapper>
|
||||
</TabsContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const PinnedApps: FC = () => {
|
||||
const { pinned, updatePinnedMinapps } = useMinapps()
|
||||
const { t } = useTranslation()
|
||||
const { minappShow } = useRuntime()
|
||||
const { minappShow, openedKeepAliveMinapps, currentMinappId } = useRuntime()
|
||||
const { theme } = useTheme()
|
||||
const { openMinappKeepAlive } = useMinappPopup()
|
||||
|
||||
return (
|
||||
<DragableList list={pinned} onUpdate={updatePinnedMinapps} listStyle={{ marginBottom: 5 }}>
|
||||
@ -187,12 +276,15 @@ const PinnedApps: FC = () => {
|
||||
}
|
||||
}
|
||||
]
|
||||
const isActive = minappShow && MinApp.app?.id === app.id
|
||||
const isActive = minappShow && currentMinappId === app.id
|
||||
return (
|
||||
<Tooltip key={app.id} title={app.name} mouseEnterDelay={0.8} placement="right">
|
||||
<StyledLink>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
|
||||
<Icon theme={theme} onClick={() => MinApp.start(app)} className={isActive ? 'active' : ''}>
|
||||
<Dropdown menu={{ items: menuItems }} trigger={['contextMenu']} overlayStyle={{ zIndex: 10000 }}>
|
||||
<Icon
|
||||
theme={theme}
|
||||
onClick={() => openMinappKeepAlive(app)}
|
||||
className={`${isActive ? 'active' : ''} ${openedKeepAliveMinapps.some((item) => item.id === app.id) ? 'opened-animation' : ''}`}>
|
||||
<MinAppIcon size={20} app={app} style={{ borderRadius: 6 }} />
|
||||
</Icon>
|
||||
</Dropdown>
|
||||
@ -293,6 +385,23 @@ const Icon = styled.div<{ theme: string }>`
|
||||
color: var(--color-icon-white);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes borderBreath {
|
||||
0% {
|
||||
border-color: var(--color-primary-mute);
|
||||
}
|
||||
50% {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
100% {
|
||||
border-color: var(--color-primary-mute);
|
||||
}
|
||||
}
|
||||
|
||||
&.opened-animation {
|
||||
border: 0.5px solid var(--color-primary);
|
||||
animation: borderBreath 4s ease-in-out infinite;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledLink = styled.div`
|
||||
@ -323,4 +432,37 @@ const Divider = styled.div`
|
||||
border-bottom: 0.5px solid var(--color-border);
|
||||
`
|
||||
|
||||
const TabsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
-webkit-app-region: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: var(--indicator-right, 0);
|
||||
top: var(--indicator-top, 0);
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
background-color: var(--color-primary);
|
||||
transition:
|
||||
top 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
right 0.3s ease-in-out;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
const TabsWrapper = styled.div`
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
export default Sidebar
|
||||
|
||||
@ -49,9 +49,7 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png
|
||||
import GroqProviderLogo from '@renderer/assets/images/providers/groq.png?url'
|
||||
import OpenAiProviderLogo from '@renderer/assets/images/providers/openai.png?url'
|
||||
import SiliconFlowProviderLogo from '@renderer/assets/images/providers/silicon.png?url'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
|
||||
export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
{
|
||||
id: 'openai',
|
||||
@ -395,8 +393,3 @@ export const DEFAULT_MIN_APPS: MinAppType[] = [
|
||||
bodered: true
|
||||
}
|
||||
]
|
||||
|
||||
export function startMinAppById(id: string) {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||
app && MinApp.start(app)
|
||||
}
|
||||
|
||||
120
src/renderer/src/hooks/useMinappPopup.ts
Normal file
120
src/renderer/src/hooks/useMinappPopup.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setCurrentMinappId,
|
||||
setMinappShow,
|
||||
setOpenedKeepAliveMinapps,
|
||||
setOpenedOneOffMinapp
|
||||
} from '@renderer/store/runtime'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
|
||||
/** The max number of keep alive minapps */
|
||||
const MINAPP_MAX_KEEPALIVE = 3
|
||||
|
||||
/**
|
||||
* Usage:
|
||||
*
|
||||
* To control the minapp popup, you can use the following hooks:
|
||||
* import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
*
|
||||
* in the component:
|
||||
* const { openMinapp, openMinappKeepAlive, openMinappById,
|
||||
* closeMinapp, hideMinappPopup, closeAllMinapps } = useMinappPopup()
|
||||
*
|
||||
* To use some key states of the minapp popup:
|
||||
* import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
* const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||
*/
|
||||
export const useMinappPopup = () => {
|
||||
const { openedKeepAliveMinapps, openedOneOffMinapp, minappShow } = useRuntime()
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
/** Open a minapp (popup shows and minapp loaded) */
|
||||
const openMinapp = (app: MinAppType, keepAlive: boolean = false) => {
|
||||
if (keepAlive) {
|
||||
//if the minapp is already opened, do nothing
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === app.id)) {
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
//if the minapp is not opened, open it
|
||||
//check if the keep alive minapps meet the max limit
|
||||
if (openedKeepAliveMinapps.length < MINAPP_MAX_KEEPALIVE) {
|
||||
//always put new minapp at the first
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps]))
|
||||
} else {
|
||||
//pop the last one
|
||||
dispatch(setOpenedKeepAliveMinapps([app, ...openedKeepAliveMinapps.slice(0, MINAPP_MAX_KEEPALIVE - 1)]))
|
||||
}
|
||||
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
//if the minapp is not keep alive, open it as one-off minapp
|
||||
dispatch(setOpenedOneOffMinapp(app))
|
||||
dispatch(setCurrentMinappId(app.id))
|
||||
dispatch(setMinappShow(true))
|
||||
return
|
||||
}
|
||||
|
||||
/** a wrapper of openMinapp(app, true) */
|
||||
const openMinappKeepAlive = (app: MinAppType) => {
|
||||
openMinapp(app, true)
|
||||
}
|
||||
|
||||
/** Open a minapp by id (look up the minapp in DEFAULT_MIN_APPS) */
|
||||
const openMinappById = (id: string, keepAlive: boolean = false) => {
|
||||
import('@renderer/config/minapps').then(({ DEFAULT_MIN_APPS }) => {
|
||||
const app = DEFAULT_MIN_APPS.find((app) => app?.id === id)
|
||||
if (app) {
|
||||
openMinapp(app, keepAlive)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Close a minapp immediately (popup hides and minapp unloaded) */
|
||||
const closeMinapp = (appid: string) => {
|
||||
if (openedKeepAliveMinapps.some((item) => item.id === appid)) {
|
||||
dispatch(setOpenedKeepAliveMinapps(openedKeepAliveMinapps.filter((item) => item.id !== appid)))
|
||||
} else if (openedOneOffMinapp?.id === appid) {
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
}
|
||||
|
||||
dispatch(setCurrentMinappId(''))
|
||||
dispatch(setMinappShow(false))
|
||||
return
|
||||
}
|
||||
|
||||
/** Close all minapps (popup hides and all minapps unloaded) */
|
||||
const closeAllMinapps = () => {
|
||||
dispatch(setOpenedKeepAliveMinapps([]))
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
dispatch(setCurrentMinappId(''))
|
||||
dispatch(setMinappShow(false))
|
||||
}
|
||||
|
||||
/** Hide the minapp popup (only one-off minapp unloaded) */
|
||||
const hideMinappPopup = () => {
|
||||
if (!minappShow) return
|
||||
|
||||
if (openedOneOffMinapp) {
|
||||
dispatch(setOpenedOneOffMinapp(null))
|
||||
dispatch(setCurrentMinappId(''))
|
||||
}
|
||||
dispatch(setMinappShow(false))
|
||||
}
|
||||
|
||||
return {
|
||||
openMinapp,
|
||||
openMinappKeepAlive,
|
||||
openMinappById,
|
||||
closeMinapp,
|
||||
hideMinappPopup,
|
||||
closeAllMinapps
|
||||
}
|
||||
}
|
||||
@ -542,10 +542,19 @@
|
||||
"warn.siyuan.exporting": "Exporting to Siyuan Note, please do not request export repeatedly!"
|
||||
},
|
||||
"minapp": {
|
||||
"popup": {
|
||||
"refresh": "Refresh",
|
||||
"close": "Close MinApp",
|
||||
"minimize": "Minimize MinApp",
|
||||
"devtools": "Developer Tools",
|
||||
"openExternal": "Open in Browser"
|
||||
},
|
||||
"sidebar.add.title": "Add to sidebar",
|
||||
"sidebar.remove.title": "Remove from sidebar",
|
||||
"title": "MinApp",
|
||||
"sidebar.hide.title": "Hide MinApp"
|
||||
"sidebar.close.title": "Close",
|
||||
"sidebar.closeall.title": "Close All",
|
||||
"sidebar.hide.title": "Hide MinApp",
|
||||
"title": "MinApp"
|
||||
},
|
||||
"miniwindow": {
|
||||
"clipboard": {
|
||||
|
||||
@ -542,8 +542,17 @@
|
||||
"error.yuque.no_config": "語雀のAPIアドレスまたはトークンが設定されていません"
|
||||
},
|
||||
"minapp": {
|
||||
"popup": {
|
||||
"refresh": "更新",
|
||||
"close": "ミニアプリを閉じる",
|
||||
"minimize": "ミニアプリを最小化",
|
||||
"devtools": "開発者ツール",
|
||||
"openExternal": "ブラウザで開く"
|
||||
},
|
||||
"sidebar.add.title": "サイドバーに追加",
|
||||
"sidebar.remove.title": "サイドバーから削除",
|
||||
"sidebar.close.title": "閉じる",
|
||||
"sidebar.closeall.title": "すべて閉じる",
|
||||
"sidebar.hide.title": "ミニアプリを隠す",
|
||||
"title": "ミニアプリ"
|
||||
},
|
||||
|
||||
@ -542,8 +542,17 @@
|
||||
"warn.siyuan.exporting": "Экспортируется в Siyuan, пожалуйста, не отправляйте повторные запросы!"
|
||||
},
|
||||
"minapp": {
|
||||
"popup": {
|
||||
"refresh": "Обновить",
|
||||
"close": "Закрыть встроенное приложение",
|
||||
"minimize": "Свернуть встроенное приложение",
|
||||
"devtools": "Инструменты разработчика",
|
||||
"openExternal": "Открыть в браузере"
|
||||
},
|
||||
"sidebar.add.title": "Добавить в боковую панель",
|
||||
"sidebar.remove.title": "Удалить из боковой панели",
|
||||
"sidebar.close.title": "Закрыть",
|
||||
"sidebar.closeall.title": "Закрыть все",
|
||||
"sidebar.hide.title": "Скрыть приложение",
|
||||
"title": "Встроенные приложения"
|
||||
},
|
||||
|
||||
@ -542,8 +542,17 @@
|
||||
"warn.siyuan.exporting": "正在导出到思源笔记,请勿重复请求导出!"
|
||||
},
|
||||
"minapp": {
|
||||
"popup": {
|
||||
"refresh": "刷新",
|
||||
"close": "关闭小程序",
|
||||
"minimize": "最小化小程序",
|
||||
"devtools": "开发者工具",
|
||||
"openExternal": "在浏览器中打开"
|
||||
},
|
||||
"sidebar.add.title": "添加到侧边栏",
|
||||
"sidebar.remove.title": "从侧边栏移除",
|
||||
"sidebar.close.title": "关闭",
|
||||
"sidebar.closeall.title": "全部关闭",
|
||||
"sidebar.hide.title": "隐藏小程序",
|
||||
"title": "小程序"
|
||||
},
|
||||
|
||||
@ -542,8 +542,17 @@
|
||||
"warn.siyuan.exporting": "正在導出到思源筆記,請勿重複請求導出!"
|
||||
},
|
||||
"minapp": {
|
||||
"popup": {
|
||||
"refresh": "重新整理",
|
||||
"close": "關閉小工具",
|
||||
"minimize": "最小化小工具",
|
||||
"devtools": "開發者工具",
|
||||
"openExternal": "在瀏覽器中開啟"
|
||||
},
|
||||
"sidebar.add.title": "新增到側邊欄",
|
||||
"sidebar.remove.title": "從側邊欄移除",
|
||||
"sidebar.close.title": "關閉",
|
||||
"sidebar.closeall.title": "全部關閉",
|
||||
"sidebar.hide.title": "隱藏小工具",
|
||||
"title": "小工具"
|
||||
},
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import MinAppIcon from '@renderer/components/Icons/MinAppIcon'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMinapps } from '@renderer/hooks/useMinapps'
|
||||
import { MinAppType } from '@renderer/types'
|
||||
import type { MenuProps } from 'antd'
|
||||
@ -15,13 +15,14 @@ interface Props {
|
||||
}
|
||||
|
||||
const App: FC<Props> = ({ app, onClick, size = 60 }) => {
|
||||
const { openMinappKeepAlive } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { minapps, pinned, disabled, updateMinapps, updateDisabledMinapps, updatePinnedMinapps } = useMinapps()
|
||||
const isPinned = pinned.some((p) => p.id === app.id)
|
||||
const isVisible = minapps.some((m) => m.id === app.id)
|
||||
|
||||
const handleClick = () => {
|
||||
MinApp.start(app)
|
||||
openMinappKeepAlive(app)
|
||||
onClick?.()
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { DownloadOutlined, ExpandOutlined, LinkOutlined } from '@ant-design/icons'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { extractTitle } from '@renderer/utils/formats'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
@ -14,6 +14,7 @@ interface Props {
|
||||
const Artifacts: FC<Props> = ({ html }) => {
|
||||
const { t } = useTranslation()
|
||||
const title = extractTitle(html) || 'Artifacts ' + t('chat.artifacts.button.preview')
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
/**
|
||||
* 在应用内打开
|
||||
@ -22,7 +23,8 @@ const Artifacts: FC<Props> = ({ html }) => {
|
||||
const path = await window.api.file.create('artifacts-preview.html')
|
||||
await window.api.file.write(path, html)
|
||||
const filePath = `file://${path}`
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'artifacts-preview',
|
||||
name: title,
|
||||
logo: AppLogo,
|
||||
url: filePath
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import UserPopup from '@renderer/components/Popups/UserPopup'
|
||||
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
|
||||
import { startMinAppById } from '@renderer/config/minapps'
|
||||
import { getModelLogo } from '@renderer/config/models'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import useAvatar from '@renderer/hooks/useAvatar'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useMessageStyle, useSettings } from '@renderer/hooks/useSettings'
|
||||
import { getMessageModelId } from '@renderer/services/MessagesService'
|
||||
import { getModelName } from '@renderer/services/ModelService'
|
||||
@ -32,6 +32,7 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
||||
const { userName, sidebarIcons } = useSettings()
|
||||
const { t } = useTranslation()
|
||||
const { isBubbleStyle } = useMessageStyle()
|
||||
const { openMinappById } = useMinappPopup()
|
||||
|
||||
const avatarSource = useMemo(() => getAvatarSource(isLocalAi, getMessageModelId(message)), [message])
|
||||
|
||||
@ -54,7 +55,9 @@ const MessageHeader: FC<Props> = memo(({ assistant, model, message }) => {
|
||||
const username = useMemo(() => removeLeadingEmoji(getUserName()), [getUserName])
|
||||
|
||||
const showMiniApp = useCallback(() => {
|
||||
showMinappIcon && model?.provider && startMinAppById(model.provider)
|
||||
showMinappIcon && model?.provider && openMinappById(model.provider)
|
||||
// because don't need openMinappById to be a dependency
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [model?.provider, showMinappIcon])
|
||||
|
||||
const avatarStyle: CSSProperties | undefined = isBubbleStyle
|
||||
|
||||
@ -2,9 +2,9 @@ import { GithubOutlined } from '@ant-design/icons'
|
||||
import { FileProtectOutlined, GlobalOutlined, MailOutlined, SoundOutlined } from '@ant-design/icons'
|
||||
import IndicatorLight from '@renderer/components/IndicatorLight'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { APP_NAME, AppLogo } from '@renderer/config/env'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { useRuntime } from '@renderer/hooks/useRuntime'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useAppDispatch } from '@renderer/store'
|
||||
@ -29,6 +29,7 @@ const AboutSettings: FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { update } = useRuntime()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const onCheckUpdate = debounce(
|
||||
async () => {
|
||||
@ -70,7 +71,8 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const showLicense = async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'cherrystudio-license',
|
||||
name: t('settings.about.license.title'),
|
||||
url: `file://${appPath}/resources/cherry-studio/license.html`,
|
||||
logo: AppLogo
|
||||
@ -79,7 +81,8 @@ const AboutSettings: FC = () => {
|
||||
|
||||
const showReleases = async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'cherrystudio-releases',
|
||||
name: t('settings.about.releases.title'),
|
||||
url: `file://${appPath}/resources/cherry-studio/releases.html?theme=${theme === ThemeMode.dark ? 'dark' : 'light'}`,
|
||||
logo: AppLogo
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setJoplinToken, setJoplinUrl } from '@renderer/store/settings'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@ -16,6 +16,7 @@ const JoplinSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const joplinToken = useSelector((state: RootState) => state.settings.joplinToken)
|
||||
const joplinUrl = useSelector((state: RootState) => state.settings.joplinUrl)
|
||||
@ -64,7 +65,7 @@ const JoplinSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleJoplinHelpClick = () => {
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'joplin-help',
|
||||
name: 'Joplin Help',
|
||||
url: 'https://joplinapp.org/help/apps/clipper'
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { Client } from '@notionhq/client'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import {
|
||||
setNotionApiKey,
|
||||
@ -22,6 +22,7 @@ const NotionSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const notionApiKey = useSelector((state: RootState) => state.settings.notionApiKey)
|
||||
const notionDatabaseID = useSelector((state: RootState) => state.settings.notionDatabaseID)
|
||||
@ -68,7 +69,7 @@ const NotionSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleNotionTitleClick = () => {
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'notion-help',
|
||||
name: 'Notion Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/notion'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setSiyuanApiUrl, setSiyuanBoxId, setSiyuanRootPath, setSiyuanToken } from '@renderer/store/settings'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@ -13,6 +13,7 @@ import { useSelector } from 'react-redux'
|
||||
import { SettingDivider, SettingGroup, SettingRow, SettingRowTitle, SettingTitle } from '..'
|
||||
|
||||
const SiyuanSettings: FC = () => {
|
||||
const { openMinapp } = useMinappPopup()
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
@ -39,7 +40,7 @@ const SiyuanSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleSiyuanHelpClick = () => {
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'siyuan-help',
|
||||
name: 'Siyuan Help',
|
||||
url: 'https://docs.cherry-ai.com/advanced-basic/siyuan'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { RootState, useAppDispatch } from '@renderer/store'
|
||||
import { setYuqueRepoId, setYuqueToken, setYuqueUrl } from '@renderer/store/settings'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
@ -16,6 +16,7 @@ const YuqueSettings: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const dispatch = useAppDispatch()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const yuqueToken = useSelector((state: RootState) => state.settings.yuqueToken)
|
||||
const yuqueUrl = useSelector((state: RootState) => state.settings.yuqueUrl)
|
||||
@ -64,7 +65,7 @@ const YuqueSettings: FC = () => {
|
||||
}
|
||||
|
||||
const handleYuqueHelpClick = () => {
|
||||
MinApp.start({
|
||||
openMinapp({
|
||||
id: 'yuque-help',
|
||||
name: 'Yuque Help',
|
||||
url: 'https://www.yuque.com/settings/tokens'
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import MinApp from '@renderer/components/MinApp'
|
||||
import { useMinappPopup } from '@renderer/hooks/useMinappPopup'
|
||||
import { MinAppType, Provider } from '@renderer/types'
|
||||
import { Button } from 'antd'
|
||||
import { FC } from 'react'
|
||||
@ -15,18 +15,20 @@ const GraphRAGSettings: FC<Props> = ({ provider }) => {
|
||||
const apiUrl = provider.apiHost
|
||||
const modalId = provider.models.filter((model) => model.id.includes('global'))[0]?.id
|
||||
const { t } = useTranslation()
|
||||
const { openMinapp } = useMinappPopup()
|
||||
|
||||
const onShowGraphRAG = async () => {
|
||||
const { appPath } = await window.api.getAppInfo()
|
||||
const url = `file://${appPath}/resources/graphrag.html?apiUrl=${apiUrl}&modelId=${modalId}`
|
||||
|
||||
const app: MinAppType = {
|
||||
id: 'graphrag',
|
||||
name: t('words.knowledgeGraph'),
|
||||
logo: '',
|
||||
url
|
||||
}
|
||||
|
||||
MinApp.start(app)
|
||||
openMinapp(app)
|
||||
}
|
||||
|
||||
if (!modalId) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { AppLogo, UserAvatar } from '@renderer/config/env'
|
||||
import type { MinAppType } from '@renderer/types'
|
||||
import type { UpdateInfo } from 'builder-util-runtime'
|
||||
|
||||
export interface UpdateState {
|
||||
info: UpdateInfo | null
|
||||
checking: boolean
|
||||
@ -14,7 +14,14 @@ export interface UpdateState {
|
||||
export interface RuntimeState {
|
||||
avatar: string
|
||||
generating: boolean
|
||||
/** whether the minapp popup is shown */
|
||||
minappShow: boolean
|
||||
/** the minapps that are opened and should be keep alive */
|
||||
openedKeepAliveMinapps: MinAppType[]
|
||||
/** the minapp that is opened for one time */
|
||||
openedOneOffMinapp: MinAppType | null
|
||||
/** the current minapp id */
|
||||
currentMinappId: string
|
||||
searching: boolean
|
||||
filesPath: string
|
||||
resourcesPath: string
|
||||
@ -30,6 +37,9 @@ const initialState: RuntimeState = {
|
||||
avatar: UserAvatar,
|
||||
generating: false,
|
||||
minappShow: false,
|
||||
openedKeepAliveMinapps: [],
|
||||
openedOneOffMinapp: null,
|
||||
currentMinappId: '',
|
||||
searching: false,
|
||||
filesPath: '',
|
||||
resourcesPath: '',
|
||||
@ -59,6 +69,15 @@ const runtimeSlice = createSlice({
|
||||
setMinappShow: (state, action: PayloadAction<boolean>) => {
|
||||
state.minappShow = action.payload
|
||||
},
|
||||
setOpenedKeepAliveMinapps: (state, action: PayloadAction<MinAppType[]>) => {
|
||||
state.openedKeepAliveMinapps = action.payload
|
||||
},
|
||||
setOpenedOneOffMinapp: (state, action: PayloadAction<MinAppType | null>) => {
|
||||
state.openedOneOffMinapp = action.payload
|
||||
},
|
||||
setCurrentMinappId: (state, action: PayloadAction<string>) => {
|
||||
state.currentMinappId = action.payload
|
||||
},
|
||||
setSearching: (state, action: PayloadAction<boolean>) => {
|
||||
state.searching = action.payload
|
||||
},
|
||||
@ -81,6 +100,9 @@ export const {
|
||||
setAvatar,
|
||||
setGenerating,
|
||||
setMinappShow,
|
||||
setOpenedKeepAliveMinapps,
|
||||
setOpenedOneOffMinapp,
|
||||
setCurrentMinappId,
|
||||
setSearching,
|
||||
setFilesPath,
|
||||
setResourcesPath,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user