mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-26 05:31:59 +08:00
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:
parent
3f7f78da15
commit
daf134f331
@ -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;
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user