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:
kangfenmao 2025-07-11 22:36:45 +08:00
parent b265c640ca
commit bea664af0f
4 changed files with 282 additions and 449 deletions

View File

@ -11,10 +11,100 @@ import styled, { keyframes } from 'styled-components'
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 {
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 { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
@ -23,151 +113,20 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const htmlContent = html || ''
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 path = await window.api.file.createTempFile('artifacts-preview.html')
await window.api.file.write(path, htmlContent)
const filePath = `file://${path}`
if (window.api.shell && window.api.shell.openExternal) {
if (window.api.shell?.openExternal) {
window.api.shell.openExternal(filePath)
} else {
console.error(t('artifacts.preview.openExternal.error.content'))
}
}
/**
*
*/
const handleDownload = async () => {
const fileName = `${title.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-') || 'html-artifact'}.html`
await window.api.file.save(fileName, htmlContent)
@ -202,27 +161,27 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
<TerminalLine>
<TerminalPrompt $theme={theme}>$</TerminalPrompt>
<TerminalCodeLine $theme={theme}>
{getFormattedCodePreview(htmlContent)}
{htmlContent.trim().split('\n').slice(-3).join('\n')}
<TerminalCursor $theme={theme} />
</TerminalCodeLine>
</TerminalLine>
</TerminalContent>
</TerminalPreview>
<ButtonContainer>
<Button icon={<CodeOutlined />} onClick={handleOpenInEditor} type="primary">
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="primary">
{t('chat.artifacts.button.preview')}
</Button>
</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')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} disabled={!hasContent}>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</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')}
</Button>
</ButtonContainer>
@ -230,21 +189,11 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
</Content>
</Container>
{/* 弹窗组件 */}
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={handleClosePopup} />
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
</>
)
}
const shimmer = keyframes`
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
`
const Container = styled.div<{ $isStreaming: boolean }>`
background: var(--color-background);
border: 1px solid var(--color-border);
@ -274,21 +223,7 @@ const Header = styled.div`
padding: 20px 24px 16px;
background: var(--color-background-soft);
border-bottom: 1px solid var(--color-border);
position: relative;
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 }>`
@ -297,18 +232,15 @@ const IconWrapper = styled.div<{ $isStreaming: boolean }>`
justify-content: center;
width: 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;
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;
${(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`
@ -346,7 +278,7 @@ const Content = styled.div`
`
const ButtonContainer = styled.div`
margin: 16px !important;
margin: 10px 16px !important;
display: flex;
flex-direction: row;
gap: 8px;
@ -354,7 +286,7 @@ const ButtonContainer = styled.div`
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
margin: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
background: ${(props) => getTerminalStyles(props.$theme).background};
border-radius: 8px;
overflow: hidden;
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 }>`
padding: 12px;
background: ${(props) => (props.$theme === 'dark' ? '#1e1e1e' : '#f0f0f0')};
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
background: ${(props) => getTerminalStyles(props.$theme).background};
color: ${(props) => getTerminalStyles(props.$theme).color};
font-size: 13px;
line-height: 1.4;
min-height: 80px;
@ -379,25 +311,27 @@ const TerminalCodeLine = styled.span<{ $theme: ThemeMode }>`
flex: 1;
white-space: pre-wrap;
word-break: break-word;
color: ${(props) => (props.$theme === 'dark' ? '#cccccc' : '#333333')};
color: ${(props) => getTerminalStyles(props.$theme).color};
background-color: transparent !important;
`
const TerminalPrompt = styled.span<{ $theme: ThemeMode }>`
color: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
color: ${(props) => getTerminalStyles(props.$theme).promptColor};
font-weight: bold;
flex-shrink: 0;
`
const blinkAnimation = keyframes`
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
`
const TerminalCursor = styled.span<{ $theme: ThemeMode }>`
display: inline-block;
width: 2px;
height: 16px;
background: ${(props) => (props.$theme === 'dark' ? '#00ff00' : '#007700')};
animation: ${keyframes`
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
`} 1s infinite;
background: ${(props) => getTerminalStyles(props.$theme).promptColor};
animation: ${blinkAnimation} 1s infinite;
margin-left: 2px;
`

View File

@ -1,9 +1,9 @@
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 { Button, Modal } from 'antd'
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 styled from 'styled-components'
@ -16,140 +16,41 @@ interface HtmlArtifactsPopupProps {
type ViewMode = 'split' | 'code' | 'preview'
// 视图模式配置
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 HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
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 }) => (
<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 [previewHtml, setPreviewHtml] = useState(html)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(htmlContent)
const currentRenderedHtmlRef = useRef(htmlContent)
const { t } = useTranslation()
const latestHtmlRef = useRef(html)
// 更新最新的HTML内容引用
// 当外部html更新时同步更新内部状态
useEffect(() => {
latestHtmlRef.current = htmlContent
}, [htmlContent])
setCurrentHtml(html)
latestHtmlRef.current = html
}, [html])
// 固定频率渲染 HTML 内容每2秒钟检查并更新一次
// 当内部编辑的html更新时更新引用
useEffect(() => {
// 立即设置初始内容
setDebouncedHtml(htmlContent)
currentRenderedHtmlRef.current = htmlContent
latestHtmlRef.current = currentHtml
}, [currentHtml])
// 2秒定时检查并刷新预览仅在内容变化时
useEffect(() => {
if (!open) return
// 立即设置初始预览内容
setPreviewHtml(currentHtml)
// 设置定时器每2秒检查一次内容是否有变化
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
setDebouncedHtml(latestHtmlRef.current)
currentRenderedHtmlRef.current = latestHtmlRef.current
if (latestHtmlRef.current !== previewHtml) {
setPreviewHtml(latestHtmlRef.current)
}
}, 2000) // 2秒固定频率
}, 2000)
// 清理函数
return () => {
@ -157,150 +58,164 @@ const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible
clearInterval(intervalRef.current)
}
}
}, []) // 只在组件挂载时执行一次
}, [open, previewHtml])
if (!visible) return null
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更新时同步更新内部状态
// 全屏时防止 body 滚动
useEffect(() => {
setCurrentHtml(html)
}, [html])
if (!open || !isFullscreen) return
// 计算视图可见性
const viewVisibility = useMemo(
() => ({
code: viewMode === 'split' || viewMode === 'code',
preview: viewMode === 'split' || viewMode === 'preview'
}),
[viewMode]
const body = document.body
const originalOverflow = body.style.overflow
body.style.overflow = 'hidden'
return () => {
body.style.overflow = originalOverflow
}
}, [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 (
<StyledModal
$isFullscreen={isFullscreen}
title={
<ModalHeaderComponent
title={title}
isFullscreen={isFullscreen}
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
onToggleFullscreen={toggleFullscreen}
onCancel={handleCancel}
/>
}
title={renderHeader()}
open={open}
onOk={handleOk}
onCancel={handleCancel}
afterClose={handleClose}
centered
afterClose={onClose}
centered={!isFullscreen}
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}
closable={false}>
<Container>
<CodeSectionComponent html={currentHtml} visible={viewVisibility.code} onCodeChange={handleCodeChange} />
<PreviewSectionComponent html={currentHtml} visible={viewVisibility.preview} />
{showCode && (
<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>
</StyledModal>
)
}
// 样式组件保持不变
const commonModalBodyStyles = `
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
`
// 简化的样式组件
const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
${(props) =>
props.$isFullscreen
? `
position: fixed !important;
top: 0 !important;
left: 0 !important;
z-index: 10000 !important;
.ant-modal-wrap {
padding: 0 !important;
position: fixed !important;
inset: 0 !important;
}
.ant-modal {
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
position: fixed !important;
inset: 0 !important;
}
.ant-modal-body {
height: calc(100vh - 45px) !important;
${commonModalBodyStyles}
max-height: initial !important;
}
`
: `
.ant-modal-body {
height: 80vh !important;
${commonModalBodyStyles}
min-height: 600px !important;
}
`}
.ant-modal-body {
${commonModalBodyStyles}
padding: 0 !important;
display: flex !important;
flex-direction: column !important;
max-height: initial !important;
}
.ant-modal-content {
@ -314,14 +229,8 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
padding: 10px 12px !important;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 0 !important;
margin-bottom: 0 !important;
}
.ant-modal-title {
margin: 0;
width: 100%;
}
`
const ModalHeader = styled.div`
@ -343,17 +252,15 @@ const HeaderCenter = styled.div`
left: 50%;
top: 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;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding-right: ${({ $isFullscreen }) => ($isFullscreen ? (isWin ? '136px' : isLinux ? '120px' : '12px') : '12px')};
`
const TitleText = styled.span`
@ -367,7 +274,6 @@ const TitleText = styled.span`
const ViewControls = styled.div`
display: flex;
width: auto;
gap: 8px;
padding: 4px;
background: var(--color-background-mute);
@ -404,39 +310,24 @@ const Container = styled.div`
background: var(--color-background);
`
const CodeSection = styled.div<{ $visible: boolean }>`
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`
const CodeSection = styled.div`
flex: 1;
height: 100%;
min-width: 300px;
border-right: 1px solid var(--color-border);
overflow: hidden;
.monaco-editor {
height: 100% !important;
}
.cm-editor {
height: 100% !important;
}
.monaco-editor,
.cm-editor,
.cm-scroller {
height: 100% !important;
}
`
const PreviewSection = styled.div<{ $visible: boolean }>`
flex: ${(props) => (props.$visible ? '1' : '0')};
min-width: ${(props) => (props.$visible ? '300px' : '0')};
const PreviewSection = styled.div`
flex: 1;
min-width: 300px;
background: white;
overflow: hidden;
display: ${(props) => (props.$visible ? 'block' : 'none')};
`
const PreviewFrame = styled.iframe`
@ -445,6 +336,7 @@ const PreviewFrame = styled.iframe`
border: none;
background: white;
`
const EmptyPreview = styled.div`
width: 100%;
height: 100%;

View File

@ -133,7 +133,7 @@ const Container = styled.div`
align-items: center;
gap: 10px;
position: relative;
margin-bottom: 5px;
margin-bottom: 10px;
`
const UserWrap = styled.div`

View File

@ -3,6 +3,7 @@ import CustomCollapse from '@renderer/components/CustomCollapse'
import CustomTag from '@renderer/components/CustomTag'
import ExpandableText from '@renderer/components/ExpandableText'
import ModelIdWithTags from '@renderer/components/ModelIdWithTags'
import Scrollbar from '@renderer/components/Scrollbar'
import {
getModelLogo,
groupQwenModels,
@ -218,7 +219,7 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
mouseEnterDelay={0.5}
placement="top">
<Button
type={isAllFilteredInProvider ? 'default' : 'primary'}
type="default"
icon={isAllFilteredInProvider ? <MinusOutlined /> : <PlusOutlined />}
size="large"
onClick={(e) => {
@ -302,6 +303,11 @@ const PopupContainer: React.FC<Props> = ({ provider: _provider, resolve }) => {
footer={null}
width="800px"
transitionName="animation-move-down"
styles={{
body: {
overflowY: 'hidden'
}
}}
centered>
<SearchContainer>
<TopToolsWrapper>
@ -399,7 +405,7 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
return (
<FileItem
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',
boxShadow: 'none'
}}
@ -421,7 +427,7 @@ const ModelListItem: React.FC<ModelListItemProps> = memo(({ model, provider, onA
const SearchContainer = styled.div`
display: flex;
flex-direction: column;
gap: 15px;
gap: 5px;
.ant-radio-group {
display: flex;
@ -433,15 +439,16 @@ const TopToolsWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
margin-bottom: 0;
`
const ListContainer = styled.div`
const ListContainer = styled(Scrollbar)`
height: calc(100vh - 300px);
overflow-y: scroll;
display: flex;
flex-direction: column;
gap: 16px;
padding-right: 2px;
padding-bottom: 30px;
`
const FlexColumn = styled.div`