mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-10 15:49:29 +08:00
refactor: simplify HtmlArtifactsPopup component and improve preview functionality
- Removed unnecessary extracted components and integrated their logic directly into HtmlArtifactsPopup. - Enhanced preview functionality with a debounced update mechanism for HTML content. - Updated styling for better layout and responsiveness, including fullscreen handling. - Adjusted view mode management for clearer code structure and improved user experience.
This commit is contained in:
parent
b265c640ca
commit
bea664af0f
@ -11,10 +11,100 @@ import styled, { keyframes } from 'styled-components'
|
|||||||
|
|
||||||
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
import HtmlArtifactsPopup from './HtmlArtifactsPopup'
|
||||||
|
|
||||||
|
const HTML_VOID_ELEMENTS = new Set([
|
||||||
|
'area',
|
||||||
|
'base',
|
||||||
|
'br',
|
||||||
|
'col',
|
||||||
|
'embed',
|
||||||
|
'hr',
|
||||||
|
'img',
|
||||||
|
'input',
|
||||||
|
'link',
|
||||||
|
'meta',
|
||||||
|
'param',
|
||||||
|
'source',
|
||||||
|
'track',
|
||||||
|
'wbr'
|
||||||
|
])
|
||||||
|
|
||||||
|
const HTML_COMPLETION_PATTERNS = [
|
||||||
|
/<\/html\s*>/i,
|
||||||
|
/<!DOCTYPE\s+html/i,
|
||||||
|
/<\/body\s*>/i,
|
||||||
|
/<\/div\s*>/i,
|
||||||
|
/<\/script\s*>/i,
|
||||||
|
/<\/style\s*>/i
|
||||||
|
]
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
html: string
|
html: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUnmatchedTags(html: string): boolean {
|
||||||
|
const stack: string[] = []
|
||||||
|
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = tagRegex.exec(html)) !== null) {
|
||||||
|
const [fullTag, tagName] = match
|
||||||
|
const isClosing = fullTag.startsWith('</')
|
||||||
|
const isSelfClosing = fullTag.endsWith('/>') || HTML_VOID_ELEMENTS.has(tagName.toLowerCase())
|
||||||
|
|
||||||
|
if (isSelfClosing) continue
|
||||||
|
|
||||||
|
if (isClosing) {
|
||||||
|
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack.push(tagName.toLowerCase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsStreaming(html: string): boolean {
|
||||||
|
if (!html?.trim()) return false
|
||||||
|
|
||||||
|
const trimmed = html.trim()
|
||||||
|
|
||||||
|
// 快速检查:如果有明显的完成标志,直接返回false
|
||||||
|
for (const pattern of HTML_COMPLETION_PATTERNS) {
|
||||||
|
if (pattern.test(trimmed)) {
|
||||||
|
// 特殊情况:同时有DOCTYPE和</body>
|
||||||
|
if (trimmed.includes('<!DOCTYPE') && /<\/body\s*>/i.test(trimmed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 如果只是以</html>结尾,也认为是完成的
|
||||||
|
if (/<\/html\s*>$/i.test(trimmed)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查未完成的标志
|
||||||
|
const hasIncompleteTag = /<[^>]*$/.test(trimmed)
|
||||||
|
const hasUnmatched = hasUnmatchedTags(trimmed)
|
||||||
|
|
||||||
|
if (hasIncompleteTag || hasUnmatched) return true
|
||||||
|
|
||||||
|
// 对于简单片段,如果长度较短且没有明显结束标志,可能还在生成
|
||||||
|
const hasStructureTags = /<(html|body|head)[^>]*>/i.test(trimmed)
|
||||||
|
if (!hasStructureTags && trimmed.length < 500) {
|
||||||
|
return !HTML_COMPLETION_PATTERNS.some((pattern) => pattern.test(trimmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTerminalStyles = (theme: ThemeMode) => ({
|
||||||
|
background: theme === 'dark' ? '#1e1e1e' : '#f0f0f0',
|
||||||
|
color: theme === 'dark' ? '#cccccc' : '#333333',
|
||||||
|
promptColor: theme === 'dark' ? '#00ff00' : '#007700'
|
||||||
|
})
|
||||||
|
|
||||||
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const title = extractTitle(html) || 'HTML Artifacts'
|
const title = extractTitle(html) || 'HTML Artifacts'
|
||||||
@ -23,151 +113,20 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
|||||||
|
|
||||||
const htmlContent = html || ''
|
const htmlContent = html || ''
|
||||||
const hasContent = htmlContent.trim().length > 0
|
const hasContent = htmlContent.trim().length > 0
|
||||||
|
const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent])
|
||||||
|
|
||||||
// 判断是否正在流式生成的逻辑
|
|
||||||
const isStreaming = useMemo(() => {
|
|
||||||
if (!hasContent) return false
|
|
||||||
|
|
||||||
const trimmedHtml = htmlContent.trim()
|
|
||||||
|
|
||||||
// 提前检查:如果包含关键的结束标签,直接判断为完整文档
|
|
||||||
if (/<\/html\s*>/i.test(trimmedHtml)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果同时包含 DOCTYPE 和 </body>,通常也是完整文档
|
|
||||||
if (/<!DOCTYPE\s+html/i.test(trimmedHtml) && /<\/body\s*>/i.test(trimmedHtml)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查 HTML 是否看起来是完整的
|
|
||||||
const indicators = {
|
|
||||||
// 1. 检查常见的 HTML 结构完整性
|
|
||||||
hasHtmlTag: /<html[^>]*>/i.test(trimmedHtml),
|
|
||||||
hasClosingHtmlTag: /<\/html\s*>$/i.test(trimmedHtml),
|
|
||||||
|
|
||||||
// 2. 检查 body 标签完整性
|
|
||||||
hasBodyTag: /<body[^>]*>/i.test(trimmedHtml),
|
|
||||||
hasClosingBodyTag: /<\/body\s*>/i.test(trimmedHtml),
|
|
||||||
|
|
||||||
// 3. 检查是否以未闭合的标签结尾
|
|
||||||
endsWithIncompleteTag: /<[^>]*$/.test(trimmedHtml),
|
|
||||||
|
|
||||||
// 4. 检查是否有未配对的标签
|
|
||||||
hasUnmatchedTags: checkUnmatchedTags(trimmedHtml),
|
|
||||||
|
|
||||||
// 5. 检查是否以常见的"流式结束"模式结尾
|
|
||||||
endsWithTypicalCompletion: /(<\/html>\s*|<\/body>\s*|<\/div>\s*|<\/script>\s*|<\/style>\s*)$/i.test(trimmedHtml)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有明显的未完成标志,则认为正在生成
|
|
||||||
if (indicators.endsWithIncompleteTag || indicators.hasUnmatchedTags) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有 HTML 结构但不完整
|
|
||||||
if (indicators.hasHtmlTag && !indicators.hasClosingHtmlTag) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果有 body 结构但不完整
|
|
||||||
if (indicators.hasBodyTag && !indicators.hasClosingBodyTag) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对于简单的 HTML 片段,检查是否看起来是完整的
|
|
||||||
if (!indicators.hasHtmlTag && !indicators.hasBodyTag) {
|
|
||||||
// 如果是简单片段且没有明显的结束标志,可能还在生成
|
|
||||||
return !indicators.endsWithTypicalCompletion && trimmedHtml.length < 500
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}, [htmlContent, hasContent])
|
|
||||||
|
|
||||||
// 检查未配对标签的辅助函数
|
|
||||||
function checkUnmatchedTags(html: string): boolean {
|
|
||||||
const stack: string[] = []
|
|
||||||
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g
|
|
||||||
|
|
||||||
// HTML5 void 元素(自闭合元素)的完整列表
|
|
||||||
const voidElements = [
|
|
||||||
'area',
|
|
||||||
'base',
|
|
||||||
'br',
|
|
||||||
'col',
|
|
||||||
'embed',
|
|
||||||
'hr',
|
|
||||||
'img',
|
|
||||||
'input',
|
|
||||||
'link',
|
|
||||||
'meta',
|
|
||||||
'param',
|
|
||||||
'source',
|
|
||||||
'track',
|
|
||||||
'wbr'
|
|
||||||
]
|
|
||||||
|
|
||||||
let match
|
|
||||||
|
|
||||||
while ((match = tagRegex.exec(html)) !== null) {
|
|
||||||
const [fullTag, tagName] = match
|
|
||||||
const isClosing = fullTag.startsWith('</')
|
|
||||||
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
|
|
||||||
|
|
||||||
if (isSelfClosing) continue
|
|
||||||
|
|
||||||
if (isClosing) {
|
|
||||||
if (stack.length === 0 || stack.pop() !== tagName.toLowerCase()) {
|
|
||||||
return true // 找到不匹配的闭合标签
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
stack.push(tagName.toLowerCase())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return stack.length > 0 // 还有未闭合的标签
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取格式化的代码预览
|
|
||||||
function getFormattedCodePreview(html: string): string {
|
|
||||||
const trimmed = html.trim()
|
|
||||||
const lines = trimmed.split('\n')
|
|
||||||
const lastFewLines = lines.slice(-3) // 显示最后3行
|
|
||||||
return lastFewLines.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在编辑器中打开
|
|
||||||
*/
|
|
||||||
const handleOpenInEditor = () => {
|
|
||||||
setIsPopupOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭弹窗
|
|
||||||
*/
|
|
||||||
const handleClosePopup = () => {
|
|
||||||
setIsPopupOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 外部链接打开
|
|
||||||
*/
|
|
||||||
const handleOpenExternal = async () => {
|
const handleOpenExternal = async () => {
|
||||||
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
const path = await window.api.file.createTempFile('artifacts-preview.html')
|
||||||
await window.api.file.write(path, htmlContent)
|
await window.api.file.write(path, htmlContent)
|
||||||
const filePath = `file://${path}`
|
const filePath = `file://${path}`
|
||||||
|
|
||||||
if (window.api.shell && window.api.shell.openExternal) {
|
if (window.api.shell?.openExternal) {
|
||||||
window.api.shell.openExternal(filePath)
|
window.api.shell.openExternal(filePath)
|
||||||
} else {
|
} else {
|
||||||
console.error(t('artifacts.preview.openExternal.error.content'))
|
console.error(t('artifacts.preview.openExternal.error.content'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 下载到本地
|
|
||||||
*/
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
|
||||||
await window.api.file.save(fileName, htmlContent)
|
await window.api.file.save(fileName, htmlContent)
|
||||||
@ -202,27 +161,27 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
|||||||
<TerminalLine>
|
<TerminalLine>
|
||||||
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
|
||||||
<TerminalCodeLine $theme={theme}>
|
<TerminalCodeLine $theme={theme}>
|
||||||
{getFormattedCodePreview(htmlContent)}
|
{htmlContent.trim().split('\n').slice(-3).join('\n')}
|
||||||
<TerminalCursor $theme={theme} />
|
<TerminalCursor $theme={theme} />
|
||||||
</TerminalCodeLine>
|
</TerminalCodeLine>
|
||||||
</TerminalLine>
|
</TerminalLine>
|
||||||
</TerminalContent>
|
</TerminalContent>
|
||||||
</TerminalPreview>
|
</TerminalPreview>
|
||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
|
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="primary">
|
||||||
{t('chat.artifacts.button.preview')}
|
{t('chat.artifacts.button.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<ButtonContainer>
|
<ButtonContainer>
|
||||||
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary" disabled={!hasContent}>
|
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
|
||||||
{t('chat.artifacts.button.preview')}
|
{t('chat.artifacts.button.preview')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
|
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
|
||||||
{t('chat.artifacts.button.openExternal')}
|
{t('chat.artifacts.button.openExternal')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon={<Download size={16} />} onClick={handleDownload} disabled={!hasContent}>
|
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
|
||||||
{t('code_block.download')}
|
{t('code_block.download')}
|
||||||
</Button>
|
</Button>
|
||||||
</ButtonContainer>
|
</ButtonContainer>
|
||||||
@ -230,21 +189,11 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
|
|||||||
</Content>
|
</Content>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{/* 弹窗组件 */}
|
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
|
||||||
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const shimmer = keyframes`
|
|
||||||
0% {
|
|
||||||
background-position: -200px 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: calc(200px + 100%) 0;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const Container = styled.div<{ $isStreaming: boolean }>`
|
const Container = styled.div<{ $isStreaming: boolean }>`
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@ -274,21 +223,7 @@ const Header = styled.div`
|
|||||||
padding: 20px 24px 16px;
|
padding: 20px 24px 16px;
|
||||||
background: var(--color-background-soft);
|
background: var(--color-background-soft);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
position: relative;
|
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #06b6d4);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
animation: ${shimmer} 3s ease-in-out infinite;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
||||||
@ -297,18 +232,15 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
background: ${(props) =>
|
||||||
|
props.$isStreaming
|
||||||
|
? 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)'
|
||||||
|
: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)'};
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: white;
|
color: white;
|
||||||
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
|
box-shadow: ${(props) =>
|
||||||
|
props.$isStreaming ? '0 4px 6px -1px rgba(245, 158, 11, 0.3)' : '0 4px 6px -1px rgba(59, 130, 246, 0.3)'};
|
||||||
transition: background 0.3s ease;
|
transition: background 0.3s ease;
|
||||||
|
|
||||||
${(props) =>
|
|
||||||
props.$isStreaming &&
|
|
||||||
`
|
|
||||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); /* Darker orange for loading */
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(245, 158, 11, 0.3);
|
|
||||||
`}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const TitleSection = styled.div`
|
const TitleSection = styled.div`
|
||||||
@ -346,7 +278,7 @@ const Content = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const ButtonContainer = styled.div`
|
const ButtonContainer = styled.div`
|
||||||
margin: 16px !important;
|
margin: 10px 16px !important;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -354,7 +286,7 @@ const ButtonContainer = styled.div`
|
|||||||
|
|
||||||
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
||||||
margin: 16px;
|
margin: 16px;
|
||||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
background: ${(props) => getTerminalStyles(props.$theme).background};
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
|
||||||
@ -362,8 +294,8 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
|
|||||||
|
|
||||||
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
const TerminalContent = styled.div<{ $theme: ThemeMode }>`
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
|
background: ${(props) => getTerminalStyles(props.$theme).background};
|
||||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
color: ${(props) => getTerminalStyles(props.$theme).color};
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
@ -379,25 +311,27 @@ const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
|
color: ${(props) => getTerminalStyles(props.$theme).color};
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
`
|
`
|
||||||
|
|
||||||
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
|
||||||
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
color: ${(props) => getTerminalStyles(props.$theme).promptColor};
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const blinkAnimation = keyframes`
|
||||||
|
0%, 50% { opacity: 1; }
|
||||||
|
51%, 100% { opacity: 0; }
|
||||||
|
`
|
||||||
|
|
||||||
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
|
background: ${(props) => getTerminalStyles(props.$theme).promptColor};
|
||||||
animation: ${keyframes`
|
animation: ${blinkAnimation} 1s infinite;
|
||||||
0%, 50% { opacity: 1; }
|
|
||||||
51%, 100% { opacity: 0; }
|
|
||||||
`} 1s infinite;
|
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import CodeEditor from '@renderer/components/CodeEditor'
|
import CodeEditor from '@renderer/components/CodeEditor'
|
||||||
import { isMac } from '@renderer/config/constant'
|
import { isLinux, isMac, isWin } from '@renderer/config/constant'
|
||||||
import { classNames } from '@renderer/utils'
|
import { classNames } from '@renderer/utils'
|
||||||
import { Button, Modal } from 'antd'
|
import { Button, Modal } from 'antd'
|
||||||
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
import { Code, Maximize2, Minimize2, Monitor, MonitorSpeaker, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
@ -16,140 +16,41 @@ interface HtmlArtifactsPopupProps {
|
|||||||
|
|
||||||
type ViewMode = 'split' | 'code' | 'preview'
|
type ViewMode = 'split' | 'code' | 'preview'
|
||||||
|
|
||||||
// 视图模式配置
|
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
||||||
const VIEW_MODE_CONFIG = {
|
|
||||||
split: {
|
|
||||||
key: 'split' as const,
|
|
||||||
icon: MonitorSpeaker,
|
|
||||||
i18nKey: 'html_artifacts.split'
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
key: 'code' as const,
|
|
||||||
icon: Code,
|
|
||||||
i18nKey: 'html_artifacts.code'
|
|
||||||
},
|
|
||||||
preview: {
|
|
||||||
key: 'preview' as const,
|
|
||||||
icon: Monitor,
|
|
||||||
i18nKey: 'html_artifacts.preview'
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
|
|
||||||
// 抽取头部组件
|
|
||||||
interface ModalHeaderProps {
|
|
||||||
title: string
|
|
||||||
isFullscreen: boolean
|
|
||||||
viewMode: ViewMode
|
|
||||||
onViewModeChange: (mode: ViewMode) => void
|
|
||||||
onToggleFullscreen: () => void
|
|
||||||
onCancel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
|
|
||||||
title,
|
|
||||||
isFullscreen,
|
|
||||||
viewMode,
|
|
||||||
onViewModeChange,
|
|
||||||
onToggleFullscreen,
|
|
||||||
onCancel
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
||||||
|
const [currentHtml, setCurrentHtml] = useState(html)
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
|
||||||
const viewButtons = useMemo(() => {
|
// 预览刷新相关状态
|
||||||
return Object.values(VIEW_MODE_CONFIG).map(({ key, icon: Icon, i18nKey }) => (
|
const [previewHtml, setPreviewHtml] = useState(html)
|
||||||
<ViewButton
|
|
||||||
key={key}
|
|
||||||
size="small"
|
|
||||||
type={viewMode === key ? 'primary' : 'default'}
|
|
||||||
icon={<Icon size={14} />}
|
|
||||||
onClick={() => onViewModeChange(key)}>
|
|
||||||
{t(i18nKey)}
|
|
||||||
</ViewButton>
|
|
||||||
))
|
|
||||||
}, [viewMode, onViewModeChange, t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
|
|
||||||
<HeaderLeft $isFullscreen={isFullscreen}>
|
|
||||||
<TitleText>{title}</TitleText>
|
|
||||||
</HeaderLeft>
|
|
||||||
<HeaderCenter>
|
|
||||||
<ViewControls>{viewButtons}</ViewControls>
|
|
||||||
</HeaderCenter>
|
|
||||||
<HeaderRight>
|
|
||||||
<Button
|
|
||||||
onClick={onToggleFullscreen}
|
|
||||||
type="text"
|
|
||||||
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
|
||||||
className="nodrag"
|
|
||||||
/>
|
|
||||||
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
|
|
||||||
</HeaderRight>
|
|
||||||
</ModalHeader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 抽取代码编辑器组件
|
|
||||||
interface CodeSectionProps {
|
|
||||||
html: string
|
|
||||||
visible: boolean
|
|
||||||
onCodeChange: (code: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCodeChange }) => {
|
|
||||||
if (!visible) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CodeSection $visible={visible}>
|
|
||||||
<CodeEditorWrapper>
|
|
||||||
<CodeEditor
|
|
||||||
value={html}
|
|
||||||
language="html"
|
|
||||||
editable={true}
|
|
||||||
onSave={onCodeChange}
|
|
||||||
style={{ height: '100%' }}
|
|
||||||
options={{
|
|
||||||
stream: false,
|
|
||||||
collapsible: false
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CodeEditorWrapper>
|
|
||||||
</CodeSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 抽取预览组件
|
|
||||||
interface PreviewSectionProps {
|
|
||||||
html: string
|
|
||||||
visible: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
|
|
||||||
const htmlContent = html || ''
|
|
||||||
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
|
|
||||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const latestHtmlRef = useRef(htmlContent)
|
const latestHtmlRef = useRef(html)
|
||||||
const currentRenderedHtmlRef = useRef(htmlContent)
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
// 更新最新的HTML内容引用
|
// 当外部html更新时,同步更新内部状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestHtmlRef.current = htmlContent
|
setCurrentHtml(html)
|
||||||
}, [htmlContent])
|
latestHtmlRef.current = html
|
||||||
|
}, [html])
|
||||||
|
|
||||||
// 固定频率渲染 HTML 内容,每2秒钟检查并更新一次
|
// 当内部编辑的html更新时,更新引用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 立即设置初始内容
|
latestHtmlRef.current = currentHtml
|
||||||
setDebouncedHtml(htmlContent)
|
}, [currentHtml])
|
||||||
currentRenderedHtmlRef.current = htmlContent
|
|
||||||
|
// 2秒定时检查并刷新预览(仅在内容变化时)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
// 立即设置初始预览内容
|
||||||
|
setPreviewHtml(currentHtml)
|
||||||
|
|
||||||
// 设置定时器,每2秒检查一次内容是否有变化
|
// 设置定时器,每2秒检查一次内容是否有变化
|
||||||
intervalRef.current = setInterval(() => {
|
intervalRef.current = setInterval(() => {
|
||||||
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
|
if (latestHtmlRef.current !== previewHtml) {
|
||||||
setDebouncedHtml(latestHtmlRef.current)
|
setPreviewHtml(latestHtmlRef.current)
|
||||||
currentRenderedHtmlRef.current = latestHtmlRef.current
|
|
||||||
}
|
}
|
||||||
}, 2000) // 2秒固定频率
|
}, 2000)
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
@ -157,150 +58,164 @@ const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible
|
|||||||
clearInterval(intervalRef.current)
|
clearInterval(intervalRef.current)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []) // 只在组件挂载时执行一次
|
}, [open, previewHtml])
|
||||||
|
|
||||||
if (!visible) return null
|
// 全屏时防止 body 滚动
|
||||||
const isHtmlEmpty = !debouncedHtml.trim()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PreviewSection $visible={visible}>
|
|
||||||
{isHtmlEmpty ? (
|
|
||||||
<EmptyPreview>
|
|
||||||
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
|
||||||
</EmptyPreview>
|
|
||||||
) : (
|
|
||||||
<PreviewFrame
|
|
||||||
key={debouncedHtml} // 强制重新创建iframe当内容变化时
|
|
||||||
srcDoc={debouncedHtml}
|
|
||||||
title="HTML Preview"
|
|
||||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PreviewSection>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主弹窗组件
|
|
||||||
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('split')
|
|
||||||
const [currentHtml, setCurrentHtml] = useState(html)
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
|
|
||||||
// 当外部html更新时,同步更新内部状态
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentHtml(html)
|
if (!open || !isFullscreen) return
|
||||||
}, [html])
|
|
||||||
|
|
||||||
// 计算视图可见性
|
const body = document.body
|
||||||
const viewVisibility = useMemo(
|
const originalOverflow = body.style.overflow
|
||||||
() => ({
|
body.style.overflow = 'hidden'
|
||||||
code: viewMode === 'split' || viewMode === 'code',
|
|
||||||
preview: viewMode === 'split' || viewMode === 'preview'
|
return () => {
|
||||||
}),
|
body.style.overflow = originalOverflow
|
||||||
[viewMode]
|
}
|
||||||
|
}, [isFullscreen, open])
|
||||||
|
|
||||||
|
const showCode = viewMode === 'split' || viewMode === 'code'
|
||||||
|
const showPreview = viewMode === 'split' || viewMode === 'preview'
|
||||||
|
|
||||||
|
const renderHeader = () => (
|
||||||
|
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
|
||||||
|
<HeaderLeft $isFullscreen={isFullscreen}>
|
||||||
|
<TitleText>{title}</TitleText>
|
||||||
|
</HeaderLeft>
|
||||||
|
|
||||||
|
<HeaderCenter>
|
||||||
|
<ViewControls>
|
||||||
|
<ViewButton
|
||||||
|
size="small"
|
||||||
|
type={viewMode === 'split' ? 'primary' : 'default'}
|
||||||
|
icon={<MonitorSpeaker size={14} />}
|
||||||
|
onClick={() => setViewMode('split')}>
|
||||||
|
{t('html_artifacts.split')}
|
||||||
|
</ViewButton>
|
||||||
|
<ViewButton
|
||||||
|
size="small"
|
||||||
|
type={viewMode === 'code' ? 'primary' : 'default'}
|
||||||
|
icon={<Code size={14} />}
|
||||||
|
onClick={() => setViewMode('code')}>
|
||||||
|
{t('html_artifacts.code')}
|
||||||
|
</ViewButton>
|
||||||
|
<ViewButton
|
||||||
|
size="small"
|
||||||
|
type={viewMode === 'preview' ? 'primary' : 'default'}
|
||||||
|
icon={<Monitor size={14} />}
|
||||||
|
onClick={() => setViewMode('preview')}>
|
||||||
|
{t('html_artifacts.preview')}
|
||||||
|
</ViewButton>
|
||||||
|
</ViewControls>
|
||||||
|
</HeaderCenter>
|
||||||
|
|
||||||
|
<HeaderRight $isFullscreen={isFullscreen}>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
|
type="text"
|
||||||
|
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
|
||||||
|
className="nodrag"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} type="text" icon={<X size={16} />} className="nodrag" />
|
||||||
|
</HeaderRight>
|
||||||
|
</ModalHeader>
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算Modal属性
|
|
||||||
const modalProps = useMemo(
|
|
||||||
() => ({
|
|
||||||
width: isFullscreen ? '100vw' : '90vw',
|
|
||||||
height: isFullscreen ? '100vh' : 'auto',
|
|
||||||
style: { maxWidth: isFullscreen ? '100vw' : '1400px' }
|
|
||||||
}),
|
|
||||||
[isFullscreen]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleOk = useCallback(() => {
|
|
||||||
onClose()
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
|
||||||
onClose()
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
onClose()
|
|
||||||
}, [onClose])
|
|
||||||
|
|
||||||
const handleCodeChange = useCallback((newCode: string) => {
|
|
||||||
setCurrentHtml(newCode)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
|
||||||
setIsFullscreen((prev) => !prev)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
|
||||||
setViewMode(mode)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledModal
|
<StyledModal
|
||||||
$isFullscreen={isFullscreen}
|
$isFullscreen={isFullscreen}
|
||||||
title={
|
title={renderHeader()}
|
||||||
<ModalHeaderComponent
|
|
||||||
title={title}
|
|
||||||
isFullscreen={isFullscreen}
|
|
||||||
viewMode={viewMode}
|
|
||||||
onViewModeChange={handleViewModeChange}
|
|
||||||
onToggleFullscreen={toggleFullscreen}
|
|
||||||
onCancel={handleCancel}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
open={open}
|
open={open}
|
||||||
onOk={handleOk}
|
afterClose={onClose}
|
||||||
onCancel={handleCancel}
|
centered={!isFullscreen}
|
||||||
afterClose={handleClose}
|
|
||||||
centered
|
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
{...modalProps}
|
mask={!isFullscreen}
|
||||||
|
maskClosable={false}
|
||||||
|
width={isFullscreen ? '100vw' : '90vw'}
|
||||||
|
style={{
|
||||||
|
maxWidth: isFullscreen ? '100vw' : '1400px',
|
||||||
|
height: isFullscreen ? '100vh' : 'auto'
|
||||||
|
}}
|
||||||
|
zIndex={isFullscreen ? 10000 : 1000}
|
||||||
footer={null}
|
footer={null}
|
||||||
closable={false}>
|
closable={false}>
|
||||||
<Container>
|
<Container>
|
||||||
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
|
{showCode && (
|
||||||
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
|
<CodeSection>
|
||||||
|
<CodeEditor
|
||||||
|
value={currentHtml}
|
||||||
|
language="html"
|
||||||
|
editable={true}
|
||||||
|
onSave={setCurrentHtml}
|
||||||
|
style={{ height: '100%' }}
|
||||||
|
options={{
|
||||||
|
stream: false,
|
||||||
|
collapsible: false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CodeSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPreview && (
|
||||||
|
<PreviewSection>
|
||||||
|
{previewHtml.trim() ? (
|
||||||
|
<PreviewFrame
|
||||||
|
key={previewHtml} // 强制重新创建iframe当预览内容变化时
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
title="HTML Preview"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyPreview>
|
||||||
|
<p>{t('html_artifacts.empty_preview', 'No content to preview')}</p>
|
||||||
|
</EmptyPreview>
|
||||||
|
)}
|
||||||
|
</PreviewSection>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</StyledModal>
|
</StyledModal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 样式组件保持不变
|
// 简化的样式组件
|
||||||
const commonModalBodyStyles = `
|
|
||||||
padding: 0 !important;
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: column !important;
|
|
||||||
`
|
|
||||||
|
|
||||||
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.$isFullscreen
|
props.$isFullscreen
|
||||||
? `
|
? `
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
z-index: 10000 !important;
|
||||||
|
|
||||||
.ant-modal-wrap {
|
.ant-modal-wrap {
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-modal {
|
.ant-modal {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
|
position: fixed !important;
|
||||||
|
inset: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-modal-body {
|
.ant-modal-body {
|
||||||
height: calc(100vh - 45px) !important;
|
height: calc(100vh - 45px) !important;
|
||||||
${commonModalBodyStyles}
|
|
||||||
max-height: initial !important;
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
: `
|
: `
|
||||||
.ant-modal-body {
|
.ant-modal-body {
|
||||||
height: 80vh !important;
|
height: 80vh !important;
|
||||||
${commonModalBodyStyles}
|
|
||||||
min-height: 600px !important;
|
min-height: 600px !important;
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|
||||||
.ant-modal-body {
|
.ant-modal-body {
|
||||||
${commonModalBodyStyles}
|
padding: 0 !important;
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
max-height: initial !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-modal-content {
|
.ant-modal-content {
|
||||||
@ -314,14 +229,8 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
|
|||||||
padding: 10px 12px !important;
|
padding: 10px 12px !important;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
border-radius: 0 !important;
|
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-modal-title {
|
|
||||||
margin: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const ModalHeader = styled.div`
|
const ModalHeader = styled.div`
|
||||||
@ -343,17 +252,15 @@ const HeaderCenter = styled.div`
|
|||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 1;
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const HeaderRight = styled.div`
|
const HeaderRight = styled.div<{ $isFullscreen?: boolean }>`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
|
||||||
`
|
`
|
||||||
|
|
||||||
const TitleText = styled.span`
|
const TitleText = styled.span`
|
||||||
@ -367,7 +274,6 @@ const TitleText = styled.span`
|
|||||||
|
|
||||||
const ViewControls = styled.div`
|
const ViewControls = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
width: auto;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
background: var(--color-background-mute);
|
background: var(--color-background-mute);
|
||||||
@ -404,39 +310,24 @@ const Container = styled.div`
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
`
|
`
|
||||||
|
|
||||||
const CodeSection = styled.div<{ $visible: boolean }>`
|
const CodeSection = styled.div`
|
||||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
|
||||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
|
||||||
border-right: ${(props) => (props.$visible ? '1px solid var(--color-border)' : 'none')};
|
|
||||||
overflow: hidden;
|
|
||||||
display: ${(props) => (props.$visible ? 'flex' : 'none')};
|
|
||||||
flex-direction: column;
|
|
||||||
`
|
|
||||||
|
|
||||||
const CodeEditorWrapper = styled.div`
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
min-width: 300px;
|
||||||
|
border-right: 1px solid var(--color-border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.monaco-editor {
|
.monaco-editor,
|
||||||
height: 100% !important;
|
.cm-editor,
|
||||||
}
|
|
||||||
|
|
||||||
.cm-editor {
|
|
||||||
height: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cm-scroller {
|
.cm-scroller {
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const PreviewSection = styled.div<{ $visible: boolean }>`
|
const PreviewSection = styled.div`
|
||||||
flex: ${(props) => (props.$visible ? '1' : '0')};
|
flex: 1;
|
||||||
min-width: ${(props) => (props.$visible ? '300px' : '0')};
|
min-width: 300px;
|
||||||
background: white;
|
background: white;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: ${(props) => (props.$visible ? 'block' : 'none')};
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const PreviewFrame = styled.iframe`
|
const PreviewFrame = styled.iframe`
|
||||||
@ -445,6 +336,7 @@ const PreviewFrame = styled.iframe`
|
|||||||
border: none;
|
border: none;
|
||||||
background: white;
|
background: white;
|
||||||
`
|
`
|
||||||
|
|
||||||
const EmptyPreview = styled.div`
|
const EmptyPreview = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@ -133,7 +133,7 @@ const Container = styled.div`
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 10px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const UserWrap = styled.div`
|
const UserWrap = styled.div`
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import CustomCollapse from '@renderer/components/CustomCollapse'
|
|||||||
import CustomTag from '@renderer/components/CustomTag'
|
import CustomTag from '@renderer/components/CustomTag'
|
||||||
import ExpandableText from '@renderer/components/ExpandableText'
|
import ExpandableText from '@renderer/components/ExpandableText'
|
||||||
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
|
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
|
||||||
|
import Scrollbar from '@renderer/components/Scrollbar'
|
||||||
import {
|
import {
|
||||||
getModelLogo,
|
getModelLogo,
|
||||||
groupQwenModels,
|
groupQwenModels,
|
||||||
@ -218,7 +219,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
|||||||
mouseEnterDelay={0.5}
|
mouseEnterDelay={0.5}
|
||||||
placement="top">
|
placement="top">
|
||||||
<Button
|
<Button
|
||||||
type={isAllFilteredInProvider ? 'default' : 'primary'}
|
type="default"
|
||||||
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
|
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
|
||||||
size="large"
|
size="large"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@ -302,6 +303,11 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
|
|||||||
footer={null}
|
footer={null}
|
||||||
width="800px"
|
width="800px"
|
||||||
transitionName="animation-move-down"
|
transitionName="animation-move-down"
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
overflowY: 'hidden'
|
||||||
|
}
|
||||||
|
}}
|
||||||
centered>
|
centered>
|
||||||
<SearchContainer>
|
<SearchContainer>
|
||||||
<TopToolsWrapper>
|
<TopToolsWrapper>
|
||||||
@ -399,7 +405,7 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
|
|||||||
return (
|
return (
|
||||||
<FileItem
|
<FileItem
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isAdded ? 'rgba(0, 126, 0, 0.06)' : 'rgba(255, 255, 255, 0.04)',
|
backgroundColor: isAdded ? 'rgba(0, 126, 0, 0.06)' : '',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
boxShadow: 'none'
|
boxShadow: 'none'
|
||||||
}}
|
}}
|
||||||
@ -421,7 +427,7 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
|
|||||||
const SearchContainer = styled.div`
|
const SearchContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 15px;
|
gap: 5px;
|
||||||
|
|
||||||
.ant-radio-group {
|
.ant-radio-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -433,15 +439,16 @@ const TopToolsWrapper = styled.div`
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 0;
|
||||||
`
|
`
|
||||||
|
|
||||||
const ListContainer = styled.div`
|
const ListContainer = styled(Scrollbar)`
|
||||||
height: calc(100vh - 300px);
|
height: calc(100vh - 300px);
|
||||||
overflow-y: scroll;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding-right: 2px;
|
padding-bottom: 30px;
|
||||||
`
|
`
|
||||||
|
|
||||||
const FlexColumn = styled.div`
|
const FlexColumn = styled.div`
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user