mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +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'
|
||||
|
||||
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;
|
||||
`
|
||||
|
||||
|
||||
@ -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%;
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user