refactor(HtmlArtifacts): enhance HTML validation and rendering logic

- Added checks for complete HTML documents based on presence of critical tags.
- Updated unmatched tag detection to include a comprehensive list of HTML5 void elements.
- Improved HTML content rendering with a fixed interval update mechanism.
- Adjusted modal header styles for better layout consistency.
- Enabled editing capabilities in the CodeEditor component for HTML content.
This commit is contained in:
kangfenmao 2025-07-10 12:28:25 +08:00
parent 3f7f78da15
commit daf134f331
4 changed files with 62 additions and 26 deletions

View File

@ -30,6 +30,16 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
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 结构完整性
@ -78,13 +88,31 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
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('/>') || ['img', 'br', 'hr', 'input', 'meta', 'link'].includes(tagName.toLowerCase())
const isSelfClosing = fullTag.endsWith('/>') || voidElements.includes(tagName.toLowerCase())
if (isSelfClosing) continue
@ -350,7 +378,7 @@ const Content = styled.div`
`
const ButtonContainer = styled.div`
margin: 16px;
margin: 16px !important;
display: flex;
flex-direction: row;
gap: 8px;

View File

@ -1,5 +1,6 @@
import CodeEditor from '@renderer/components/CodeEditor'
import { isMac } 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'
@ -68,7 +69,7 @@ const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
}, [viewMode, onViewModeChange, t])
return (
<ModalHeader>
<ModalHeader onDoubleClick={onToggleFullscreen} className={classNames({ drag: isFullscreen })}>
<HeaderLeft $isFullscreen={isFullscreen}>
<TitleText>{title}</TitleText>
</HeaderLeft>
@ -80,8 +81,9 @@ const ModalHeaderComponent: React.FC<ModalHeaderProps> = ({
onClick={onToggleFullscreen}
type="text"
icon={isFullscreen ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
className="nodrag"
/>
<Button onClick={onCancel} type="text" icon={<X size={16} />} />
<Button onClick={onCancel} type="text" icon={<X size={16} />} className="nodrag" />
</HeaderRight>
</ModalHeader>
)
@ -103,7 +105,7 @@ const CodeSectionComponent: React.FC<CodeSectionProps> = ({ html, visible, onCod
<CodeEditor
value={html}
language="html"
editable={false}
editable={true}
onSave={onCodeChange}
style={{ height: '100%' }}
options={{
@ -125,26 +127,37 @@ interface PreviewSectionProps {
const PreviewSectionComponent: React.FC<PreviewSectionProps> = ({ html, visible }) => {
const htmlContent = html || ''
const [debouncedHtml, setDebouncedHtml] = useState(htmlContent)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(htmlContent)
const currentRenderedHtmlRef = useRef(htmlContent)
const { t } = useTranslation()
// 防抖更新 HTML 内容,避免过于频繁的刷新
// 更新最新的HTML内容引用
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
latestHtmlRef.current = htmlContent
}, [htmlContent])
timeoutRef.current = setTimeout(() => {
setDebouncedHtml(htmlContent)
}, 300) // 300ms 防抖延迟
// 固定频率渲染 HTML 内容每2秒钟检查并更新一次
useEffect(() => {
// 立即设置初始内容
setDebouncedHtml(htmlContent)
currentRenderedHtmlRef.current = htmlContent
// 设置定时器每2秒检查一次内容是否有变化
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentRenderedHtmlRef.current) {
setDebouncedHtml(latestHtmlRef.current)
currentRenderedHtmlRef.current = latestHtmlRef.current
}
}, 2000) // 2秒固定频率
// 清理函数
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [htmlContent])
}, []) // 只在组件挂载时执行一次
if (!visible) return null
const isHtmlEmpty = !debouncedHtml.trim()
@ -239,6 +252,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
onCancel={handleCancel}
afterClose={handleClose}
centered
destroyOnClose
{...modalProps}
footer={null}
closable={false}>
@ -297,7 +311,7 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
}
.ant-modal-header {
padding: 10px 24px !important;
padding: 10px 12px !important;
border-bottom: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 0 !important;
@ -321,7 +335,7 @@ const ModalHeader = styled.div`
const HeaderLeft = styled.div<{ $isFullscreen?: boolean }>`
flex: 1;
min-width: 0;
padding-left: ${(props) => (props.$isFullscreen && isMac ? '70px' : 0)};
padding-left: ${(props) => (props.$isFullscreen && isMac ? '65px' : '12px')};
`
const HeaderCenter = styled.div`

View File

@ -24,7 +24,6 @@ interface Props {
children: string
language: string
onSave?: (newContent: string) => void
isStreaming?: boolean
}
/**
@ -230,11 +229,6 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
)
}, [specialView, sourceView, viewMode])
const renderArtifacts = useMemo(() => {
// HTML artifacts 已经在早期返回中处理
return null
}, [])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html') {
return <HtmlArtifactsCard html={children} />
@ -245,7 +239,6 @@ const CodeBlockView: React.FC<Props> = ({ children, language, onSave }) => {
{renderHeader}
<CodeToolbar tools={tools} />
{renderContent}
{renderArtifacts}
{isExecutable && output && <StatusBar>{output}</StatusBar>}
</CodeBlockWrapper>
)

View File

@ -103,6 +103,7 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
token: {
colorPrimary: colorPrimary,
fontFamily: 'var(--font-family)',
colorBgMask: _theme === 'dark' ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.8)',
motionDurationMid: '100ms'
}
}}>