refactor: improve html artifact style (#9242)

* refactor: use code font family in HtmlArtifactsCard

* fix: pass onSave to HtmlArtifactsPopup

* feat: add a save button

* fix: avoid extra blank lines

* feat: make split view resizable

* refactor: improve streaming check, simplify Markdown component

* refactor: improve button style and icons

* test: update snapshots, add tests

* refactor: move font family to TerminalPreview

* test: update

* refactor: add explicit type for Node

* refactor: remove min-height

* fix: type

* refactor: improve scrollbar and splitter style
This commit is contained in:
one 2025-08-17 19:42:40 +08:00 committed by GitHub
parent b53a5aa3af
commit 33ec5c5c6b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 414 additions and 279 deletions

View File

@ -184,3 +184,28 @@
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
}
}
.ant-splitter-bar {
.ant-splitter-bar-dragger {
&::before {
background-color: var(--color-border) !important;
transition:
background-color 0.15s ease,
width 0.15s ease;
}
&:hover {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
transition-delay: 0.15s;
}
}
}
.ant-splitter-bar-dragger-active {
&::before {
width: 4px !important;
background-color: var(--color-primary) !important;
}
}
}

View File

@ -1,11 +1,11 @@
import { CodeOutlined, LinkOutlined } from '@ant-design/icons'
import { CodeOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import { useTheme } from '@renderer/context/ThemeProvider'
import { ThemeMode } from '@renderer/types'
import { extractTitle } from '@renderer/utils/formats'
import { Button } from 'antd'
import { Code, Download, Globe, Sparkles } from 'lucide-react'
import { FC, useMemo, useState } from 'react'
import { Code, DownloadIcon, Globe, LinkIcon, Sparkles } from 'lucide-react'
import { FC, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ClipLoader } from 'react-spinners'
import styled, { keyframes } from 'styled-components'
@ -14,92 +14,10 @@ import HtmlArtifactsPopup from './HtmlArtifactsPopup'
const logger = loggerService.withContext('HtmlArtifactsCard')
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
onSave?: (html: string) => void
isStreaming?: boolean
}
const getTerminalStyles = (theme: ThemeMode) => ({
@ -108,7 +26,7 @@ const getTerminalStyles = (theme: ThemeMode) => ({
promptColor: theme === 'dark' ? '#00ff00' : '#007700'
})
const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const HtmlArtifactsCard: FC<Props> = ({ html, onSave, isStreaming = false }) => {
const { t } = useTranslation()
const title = extractTitle(html) || 'HTML Artifacts'
const [isPopupOpen, setIsPopupOpen] = useState(false)
@ -116,7 +34,6 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
const htmlContent = html || ''
const hasContent = htmlContent.trim().length > 0
const isStreaming = useMemo(() => checkIsStreaming(htmlContent), [htmlContent])
const handleOpenExternal = async () => {
const path = await window.api.file.createTempFile('artifacts-preview.html')
@ -181,10 +98,10 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
<Button icon={<CodeOutlined />} onClick={() => setIsPopupOpen(true)} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.preview')}
</Button>
<Button icon={<LinkOutlined />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
<Button icon={<LinkIcon size={14} />} onClick={handleOpenExternal} type="text" disabled={!hasContent}>
{t('chat.artifacts.button.openExternal')}
</Button>
<Button icon={<Download size={16} />} onClick={handleDownload} type="text" disabled={!hasContent}>
<Button icon={<DownloadIcon size={14} />} onClick={handleDownload} type="text" disabled={!hasContent}>
{t('code_block.download.label')}
</Button>
</ButtonContainer>
@ -192,7 +109,13 @@ const HtmlArtifactsCard: FC<Props> = ({ html }) => {
</Content>
</Container>
<HtmlArtifactsPopup open={isPopupOpen} title={title} html={htmlContent} onClose={() => setIsPopupOpen(false)} />
<HtmlArtifactsPopup
open={isPopupOpen}
title={title}
html={htmlContent}
onSave={onSave}
onClose={() => setIsPopupOpen(false)}
/>
</>
)
}
@ -286,7 +209,6 @@ const ButtonContainer = styled.div`
margin: 10px 16px !important;
display: flex;
flex-direction: row;
gap: 8px;
`
const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
@ -294,7 +216,7 @@ const TerminalPreview = styled.div<{ $theme: ThemeMode }>`
background: ${(props) => getTerminalStyles(props.$theme).background};
border-radius: 8px;
overflow: hidden;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-family: var(--code-font-family);
`
const TerminalContent = styled.div<{ $theme: ThemeMode }>`

View File

@ -1,8 +1,8 @@
import CodeEditor from '@renderer/components/CodeEditor'
import CodeEditor, { CodeEditorHandles } from '@renderer/components/CodeEditor'
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 { Button, Modal, Splitter, Tooltip } from 'antd'
import { Code, Eye, Maximize2, Minimize2, SaveIcon, SquareSplitHorizontal, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -11,60 +11,17 @@ interface HtmlArtifactsPopupProps {
open: boolean
title: string
html: string
onSave?: (html: string) => void
onClose: () => void
}
type ViewMode = 'split' | 'code' | 'preview'
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onClose }) => {
const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, html, onSave, onClose }) => {
const { t } = useTranslation()
const [viewMode, setViewMode] = useState<ViewMode>('split')
const [currentHtml, setCurrentHtml] = useState(html)
const [isFullscreen, setIsFullscreen] = useState(false)
// Preview refresh related state
const [previewHtml, setPreviewHtml] = useState(html)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const latestHtmlRef = useRef(html)
const currentPreviewHtmlRef = useRef(html)
// Sync internal state when external html updates
useEffect(() => {
setCurrentHtml(html)
latestHtmlRef.current = html
}, [html])
// Update reference when internally edited html changes
useEffect(() => {
latestHtmlRef.current = currentHtml
}, [currentHtml])
// Update reference when preview content changes
useEffect(() => {
currentPreviewHtmlRef.current = previewHtml
}, [previewHtml])
// Check and refresh preview every 2 seconds (only when content changes)
useEffect(() => {
if (!open) return
// Set initial preview content immediately
setPreviewHtml(latestHtmlRef.current)
// Set timer to check for content changes every 2 seconds
intervalRef.current = setInterval(() => {
if (latestHtmlRef.current !== currentPreviewHtmlRef.current) {
setPreviewHtml(latestHtmlRef.current)
}
}, 2000)
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [open])
const codeEditorRef = useRef<CodeEditorHandles>(null)
// Prevent body scroll when fullscreen
useEffect(() => {
@ -79,8 +36,9 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
}
}, [isFullscreen, open])
const showCode = viewMode === 'split' || viewMode === 'code'
const showPreview = viewMode === 'split' || viewMode === 'preview'
const handleSave = () => {
codeEditorRef.current?.save?.()
}
const renderHeader = () => (
<ModalHeader onDoubleClick={() => setIsFullscreen(!isFullscreen)} className={classNames({ drag: isFullscreen })}>
@ -93,7 +51,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'split' ? 'primary' : 'default'}
icon={<MonitorSpeaker size={14} />}
icon={<SquareSplitHorizontal size={14} />}
onClick={() => setViewMode('split')}>
{t('html_artifacts.split')}
</ViewButton>
@ -107,7 +65,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
<ViewButton
size="small"
type={viewMode === 'preview' ? 'primary' : 'default'}
icon={<Monitor size={14} />}
icon={<Eye size={14} />}
onClick={() => setViewMode('preview')}>
{t('html_artifacts.preview')}
</ViewButton>
@ -126,6 +84,75 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
</ModalHeader>
)
const renderContent = () => {
const codePanel = (
<CodeSection>
<CodeEditor
ref={codeEditorRef}
value={html}
language="html"
editable={true}
onSave={onSave}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: true, // FIXME: 避免多余空行
lineNumbers: true,
keymap: true
}}
/>
<ToolbarWrapper>
<Tooltip title={t('code_block.edit.save.label')} mouseLeaveDelay={0}>
<Button
shape="circle"
size="large"
icon={<SaveIcon size={16} className="custom-lucide" />}
onClick={handleSave}
/>
</Tooltip>
</ToolbarWrapper>
</CodeSection>
)
const previewPanel = (
<PreviewSection>
{html.trim() ? (
<PreviewFrame
key={html} // Force recreate iframe when preview content changes
srcDoc={html}
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>
)
switch (viewMode) {
case 'split':
return (
<Splitter>
<Splitter.Panel defaultSize="50%" min="25%">
{codePanel}
</Splitter.Panel>
<Splitter.Panel defaultSize="50%" min="25%">
{previewPanel}
</Splitter.Panel>
</Splitter>
)
case 'code':
return codePanel
case 'preview':
return previewPanel
default:
return null
}
}
return (
<StyledModal
$isFullscreen={isFullscreen}
@ -144,41 +171,7 @@ const HtmlArtifactsPopup: React.FC<HtmlArtifactsPopupProps> = ({ open, title, ht
zIndex={isFullscreen ? 10000 : 1000}
footer={null}
closable={false}>
<Container>
{showCode && (
<CodeSection>
<CodeEditor
value={currentHtml}
language="html"
editable={true}
onSave={setCurrentHtml}
style={{ height: '100%' }}
expanded
unwrapped={false}
options={{
stream: false
}}
/>
</CodeSection>
)}
{showPreview && (
<PreviewSection>
{previewHtml.trim() ? (
<PreviewFrame
key={previewHtml} // Force recreate iframe when preview content changes
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>{renderContent()}</Container>
</StyledModal>
)
}
@ -213,7 +206,6 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
: `
.ant-modal-body {
height: 80vh !important;
min-height: 600px !important;
}
`}
@ -238,6 +230,10 @@ const StyledModal = styled(Modal)<{ $isFullscreen?: boolean }>`
margin-bottom: 0 !important;
border-radius: 0 !important;
}
::-webkit-scrollbar {
width: 8px;
}
`
const ModalHeader = styled.div`
@ -315,13 +311,24 @@ const Container = styled.div`
width: 100%;
flex: 1;
background: var(--color-background);
overflow: hidden;
.ant-splitter {
width: 100%;
height: 100%;
border: none;
.ant-splitter-pane {
overflow: hidden;
}
}
`
const CodeSection = styled.div`
flex: 1;
min-width: 300px;
border-right: 1px solid var(--color-border);
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
.monaco-editor,
.cm-editor,
@ -331,8 +338,8 @@ const CodeSection = styled.div`
`
const PreviewSection = styled.div`
flex: 1;
min-width: 300px;
height: 100%;
width: 100%;
background: white;
overflow: hidden;
`
@ -355,4 +362,15 @@ const EmptyPreview = styled.div`
font-size: 14px;
`
const ToolbarWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
position: absolute;
gap: 4px;
right: 1rem;
bottom: 1rem;
z-index: 1;
`
export default HtmlArtifactsPopup

View File

@ -1,2 +1,3 @@
export { default as HtmlArtifactsCard } from './HtmlArtifactsCard'
export * from './types'
export * from './view'

View File

@ -19,14 +19,13 @@ import { MAX_COLLAPSED_CODE_HEIGHT } from '@renderer/config/constant'
import { useSettings } from '@renderer/hooks/useSettings'
import { pyodideService } from '@renderer/services/PyodideService'
import { extractTitle } from '@renderer/utils/formats'
import { getExtensionByLanguage, isHtmlCode } from '@renderer/utils/markdown'
import { getExtensionByLanguage } from '@renderer/utils/markdown'
import dayjs from 'dayjs'
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
import { SPECIAL_VIEW_COMPONENTS, SPECIAL_VIEWS } from './constants'
import HtmlArtifactsCard from './HtmlArtifactsCard'
import StatusBar from './StatusBar'
import { ViewMode } from './types'
@ -301,11 +300,6 @@ export const CodeBlockView: React.FC<Props> = memo(({ children, language, onSave
)
}, [specialView, sourceView, viewMode])
// HTML 代码块特殊处理 - 在所有 hooks 调用之后
if (language === 'html' && isHtmlCode(children)) {
return <HtmlArtifactsCard html={children} />
}
return (
<CodeBlockWrapper className="code-block" $isInSpecialView={isInSpecialView}>
{renderHeader}

View File

@ -99,6 +99,11 @@ const AntdProvider: FC<PropsWithChildren> = ({ children }) => {
},
Divider: {
colorSplit: 'rgba(128,128,128,0.15)'
},
Splitter: {
splitBarDraggableSize: 0,
splitBarSize: 0.5,
splitTriggerSize: 10
}
},
token: {

View File

@ -1,32 +1,59 @@
import { CodeBlockView } from '@renderer/components/CodeBlockView'
import React, { memo, useCallback } from 'react'
import { CodeBlockView, HtmlArtifactsCard } from '@renderer/components/CodeBlockView'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import store from '@renderer/store'
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
import { MessageBlockStatus } from '@renderer/types/newMessage'
import { getCodeBlockId } from '@renderer/utils/markdown'
import type { Node } from 'mdast'
import React, { memo, useCallback, useMemo } from 'react'
interface Props {
children: string
className?: string
id?: string
onSave?: (id: string, newContent: string) => void
node?: Omit<Node, 'type'>
blockId: string // Message block id
[key: string]: any
}
const CodeBlock: React.FC<Props> = ({ children, className, id, onSave }) => {
const CodeBlock: React.FC<Props> = ({ children, className, node, blockId }) => {
const match = /language-([\w-+]+)/.exec(className || '') || children?.includes('\n')
const language = match?.[1] ?? 'text'
// 代码块 id
const id = useMemo(() => getCodeBlockId(node?.position?.start), [node?.position?.start])
// 消息块
const msgBlock = messageBlocksSelectors.selectById(store.getState(), blockId)
const isStreaming = useMemo(() => msgBlock?.status === MessageBlockStatus.STREAMING, [msgBlock?.status])
const handleSave = useCallback(
(newContent: string) => {
if (id !== undefined) {
onSave?.(id, newContent)
EventEmitter.emit(EVENT_NAMES.EDIT_CODE_BLOCK, {
msgBlockId: blockId,
codeBlockId: id,
newContent
})
}
},
[id, onSave]
[blockId, id]
)
return match ? (
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
) : (
if (match) {
// HTML 代码块特殊处理
// FIXME: 感觉没有必要用 isHtmlCode 判断
if (language === 'html') {
return <HtmlArtifactsCard html={children} onSave={handleSave} isStreaming={isStreaming} />
}
return (
<CodeBlockView language={language} onSave={handleSave}>
{children}
</CodeBlockView>
)
}
return (
<code className={className} style={{ textWrap: 'wrap', fontSize: '95%', padding: '2px 4px' }}>
{children}
</code>

View File

@ -1,10 +1,11 @@
import { omit } from 'lodash'
import React from 'react'
import type { Node } from 'unist'
import CitationTooltip from './CitationTooltip'
interface LinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
node?: any
node?: Omit<Node, 'type'>
citationData?: {
url: string
title?: string

View File

@ -7,11 +7,10 @@ import ImageViewer from '@renderer/components/ImageViewer'
import MarkdownShadowDOMRenderer from '@renderer/components/MarkdownShadowDOMRenderer'
import { useSettings } from '@renderer/hooks/useSettings'
import { useSmoothStream } from '@renderer/hooks/useSmoothStream'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import type { MainTextMessageBlock, ThinkingMessageBlock, TranslationMessageBlock } from '@renderer/types/newMessage'
import { parseJSON } from '@renderer/utils'
import { removeSvgEmptyLines } from '@renderer/utils/formats'
import { findCitationInChildren, getCodeBlockId, processLatexBrackets } from '@renderer/utils/markdown'
import { findCitationInChildren, processLatexBrackets } from '@renderer/utils/markdown'
import { isEmpty } from 'lodash'
import { type FC, memo, useCallback, useEffect, useMemo, useState } from 'react'
import { useRef } from 'react'
@ -126,23 +125,10 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
return plugins
}, [mathEngine, messageContent, block.id])
const onSaveCodeBlock = useCallback(
(id: string, newContent: string) => {
EventEmitter.emit(EVENT_NAMES.EDIT_CODE_BLOCK, {
msgBlockId: block.id,
codeBlockId: id,
newContent
})
},
[block.id]
)
const components = useMemo(() => {
return {
a: (props: any) => <Link {...props} citationData={parseJSON(findCitationInChildren(props.children))} />,
code: (props: any) => (
<CodeBlock {...props} id={getCodeBlockId(props?.node?.position?.start)} onSave={onSaveCodeBlock} />
),
code: (props: any) => <CodeBlock {...props} blockId={block.id} />,
table: (props: any) => <Table {...props} blockId={block.id} />,
img: (props: any) => <ImageViewer style={{ maxWidth: 500, maxHeight: 500 }} {...props} />,
pre: (props: any) => <pre style={{ overflow: 'visible' }} {...props} />,
@ -153,7 +139,7 @@ const Markdown: FC<Props> = ({ block, postProcess }) => {
},
svg: MarkdownSvgRenderer
} as Partial<Components>
}, [onSaveCodeBlock, block.id])
}, [block.id])
if (messageContent.includes('<style>')) {
components.style = MarkdownShadowDOMRenderer as any

View File

@ -7,10 +7,11 @@ import { Check } from 'lucide-react'
import React, { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import type { Node } from 'unist'
interface Props {
children: React.ReactNode
node?: any
node?: Omit<Node, 'type'>
blockId?: string
}

View File

@ -0,0 +1,147 @@
import { MessageBlockStatus } from '@renderer/types/newMessage'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CodeBlock from '../CodeBlock'
// Hoisted mocks
const mocks = vi.hoisted(() => ({
EventEmitter: {
emit: vi.fn()
},
getCodeBlockId: vi.fn(),
selectById: vi.fn(),
CodeBlockView: vi.fn(({ onSave, children }) => (
<div>
<code>{children}</code>
<button type="button" onClick={() => onSave('new code content')}>
Save
</button>
</div>
)),
HtmlArtifactsCard: vi.fn(({ onSave, html }) => (
<div>
<div>{html}</div>
<button type="button" onClick={() => onSave('new html content')}>
Save HTML
</button>
</div>
))
}))
// Mock modules
vi.mock('@renderer/services/EventService', () => ({
EVENT_NAMES: { EDIT_CODE_BLOCK: 'EDIT_CODE_BLOCK' },
EventEmitter: mocks.EventEmitter
}))
vi.mock('@renderer/utils/markdown', () => ({
getCodeBlockId: mocks.getCodeBlockId
}))
vi.mock('@renderer/store', () => ({
default: {
getState: vi.fn(() => ({})) // Mock store, state doesn't matter here
}
}))
vi.mock('@renderer/store/messageBlock', () => ({
messageBlocksSelectors: {
selectById: mocks.selectById
}
}))
vi.mock('@renderer/components/CodeBlockView', () => ({
CodeBlockView: mocks.CodeBlockView,
HtmlArtifactsCard: mocks.HtmlArtifactsCard
}))
describe('CodeBlock', () => {
const defaultProps = {
blockId: 'test-msg-block-id',
node: {
position: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 2, column: 1, offset: 2 },
value: 'console.log("hello world")'
}
},
children: 'console.log("hello world")',
className: 'language-javascript'
}
beforeEach(() => {
vi.clearAllMocks()
// Default mock return values
mocks.getCodeBlockId.mockReturnValue('test-code-block-id')
mocks.selectById.mockReturnValue({
id: 'test-msg-block-id',
status: MessageBlockStatus.SUCCESS
})
})
describe('rendering', () => {
it('should render a snapshot', () => {
const { container } = render(<CodeBlock {...defaultProps} />)
expect(container).toMatchSnapshot()
})
it('should render inline code when no language match is found', () => {
const inlineProps = {
...defaultProps,
className: undefined,
children: 'inline code'
}
render(<CodeBlock {...inlineProps} />)
const codeElement = screen.getByText('inline code')
expect(codeElement.tagName).toBe('CODE')
expect(mocks.CodeBlockView).not.toHaveBeenCalled()
})
})
describe('save', () => {
it('should call EventEmitter with correct payload when saving a standard code block', () => {
render(<CodeBlock {...defaultProps} />)
// Simulate clicking the save button inside the mocked CodeBlockView
const saveButton = screen.getByText('Save')
fireEvent.click(saveButton)
// Verify getCodeBlockId was called
expect(mocks.getCodeBlockId).toHaveBeenCalledWith(defaultProps.node.position.start)
// Verify EventEmitter.emit was called
expect(mocks.EventEmitter.emit).toHaveBeenCalledOnce()
expect(mocks.EventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
msgBlockId: 'test-msg-block-id',
codeBlockId: 'test-code-block-id',
newContent: 'new code content'
})
})
it('should call EventEmitter with correct payload when saving an HTML block', () => {
const htmlProps = {
...defaultProps,
className: 'language-html',
children: '<h1>Hello</h1>'
}
render(<CodeBlock {...htmlProps} />)
// Simulate clicking the save button inside the mocked HtmlArtifactsCard
const saveButton = screen.getByText('Save HTML')
fireEvent.click(saveButton)
// Verify getCodeBlockId was called
expect(mocks.getCodeBlockId).toHaveBeenCalledWith(htmlProps.node.position.start)
// Verify EventEmitter.emit was called
expect(mocks.EventEmitter.emit).toHaveBeenCalledOnce()
expect(mocks.EventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
msgBlockId: 'test-msg-block-id',
codeBlockId: 'test-code-block-id',
newContent: 'new html content'
})
})
})
})

View File

@ -58,12 +58,9 @@ vi.mock('@renderer/utils/markdown', () => ({
// Mock components with more realistic behavior
vi.mock('../CodeBlock', () => ({
__esModule: true,
default: ({ id, onSave, children }: any) => (
<div data-testid="code-block" data-id={id}>
default: ({ children, blockId }: any) => (
<div data-testid="code-block" data-block-id={blockId}>
<code>{children}</code>
<button type="button" onClick={() => onSave(id, 'new content')}>
Save
</button>
</div>
)
}))
@ -154,8 +151,6 @@ vi.mock('react-markdown', () => ({
}))
describe('Markdown', () => {
let mockEventEmitter: any
beforeEach(async () => {
vi.clearAllMocks()
@ -164,10 +159,6 @@ describe('Markdown', () => {
mockUseTranslation.mockReturnValue({
t: (key: string) => (key === 'message.chat.completion.paused' ? 'Paused' : key)
})
// Get mocked EventEmitter
const { EventEmitter } = await import('@renderer/services/EventService')
mockEventEmitter = EventEmitter
})
afterEach(() => {
@ -320,21 +311,9 @@ describe('Markdown', () => {
expect(screen.getByTestId('has-link-component')).toBeInTheDocument()
})
it('should integrate CodeBlock component with edit functionality', () => {
const block = createMainTextBlock({ id: 'test-block-123' })
render(<Markdown block={block} />)
it('should integrate CodeBlock component', () => {
render(<Markdown block={createMainTextBlock()} />)
expect(screen.getByTestId('has-code-component')).toBeInTheDocument()
// Test code block edit event
const saveButton = screen.getByText('Save')
saveButton.click()
expect(mockEventEmitter.emit).toHaveBeenCalledWith('EDIT_CODE_BLOCK', {
msgBlockId: 'test-block-123',
codeBlockId: 'code-block-1',
newContent: 'new content'
})
})
it('should integrate Table component with copy functionality', () => {

View File

@ -89,8 +89,8 @@ describe('Table', () => {
})
const createTablePosition = (startLine = 1, endLine = 3) => ({
start: { line: startLine },
end: { line: endLine }
start: { line: startLine, column: 1, offset: 0 },
end: { line: endLine, column: 1, offset: 2 }
})
const defaultTableContent = `| Header 1 | Header 2 |

View File

@ -0,0 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`CodeBlock > rendering > should render a snapshot 1`] = `
<div>
<div>
<code>
console.log("hello world")
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
`;

View File

@ -19,17 +19,12 @@ This is **bold** text.
data-testid="has-code-component"
>
<div
data-id="code-block-1"
data-block-id="test-block-1"
data-testid="code-block"
>
<code>
test code
</code>
<button
type="button"
>
Save
</button>
</div>
</div>
<div

View File

@ -706,11 +706,12 @@ $$
expect(isHtmlCode('<!doctype html>')).toBe(true)
})
it('should detect HTML with html/head/body tags', () => {
it('should detect HTML with valid tags', () => {
expect(isHtmlCode('<html>')).toBe(true)
expect(isHtmlCode('</html>')).toBe(true)
expect(isHtmlCode('<head>')).toBe(true)
expect(isHtmlCode('<body>')).toBe(true)
expect(isHtmlCode('<div>')).toBe(true)
})
it('should detect complete HTML structure', () => {
@ -723,7 +724,6 @@ $$
expect(isHtmlCode('')).toBe(false)
expect(isHtmlCode('Hello world')).toBe(false)
expect(isHtmlCode('a < b')).toBe(false)
expect(isHtmlCode('<div>')).toBe(false)
})
})
})

View File

@ -263,31 +263,49 @@ export function isHtmlCode(code: string | null): boolean {
return false
}
const trimmedCode = code.trim()
const trimmedCode = code.trim().toLowerCase()
// 检查是否包含HTML文档类型声明
if (trimmedCode.includes('<!DOCTYPE html>') || trimmedCode.includes('<!doctype html>')) {
// 1. 检查是否包含完整的HTML文档结构
if (
trimmedCode.includes('<!doctype html>') ||
trimmedCode.includes('<html') ||
trimmedCode.includes('</html>') ||
trimmedCode.includes('<head') ||
trimmedCode.includes('</head>') ||
trimmedCode.includes('<body') ||
trimmedCode.includes('</body>')
) {
return true
}
// 检查是否包含html标签
if (trimmedCode.includes('<html') || trimmedCode.includes('</html>')) {
// 2. 检查是否包含常见的HTML/SVG标签
const commonTags = [
'<div',
'<span',
'<p',
'<a',
'<img',
'<svg',
'<table',
'<ul',
'<ol',
'<section',
'<header',
'<footer',
'<nav',
'<article',
'<button',
'<form',
'<input'
]
if (commonTags.some((tag) => trimmedCode.includes(tag))) {
return true
}
// 检查是否包含head标签
if (trimmedCode.includes('<head>') || trimmedCode.includes('</head>')) {
return true
}
// 检查是否包含body标签
if (trimmedCode.includes('<body') || trimmedCode.includes('</body>')) {
return true
}
// 检查是否以HTML标签开头和结尾的完整HTML结构
const htmlTagPattern = /^\s*<html[^>]*>[\s\S]*<\/html>\s*$/i
if (htmlTagPattern.test(trimmedCode)) {
// 3. 检查是否存在至少一个闭合的HTML标签
// 这个正则表达式查找 <tag>...</tag> 或 <tag .../> 结构
const pairedTagPattern = /<([a-z0-9]+)([^>]*?)>(.*?)<\/\1>|<([a-z0-9]+)([^>]*?)\/>/
if (pairedTagPattern.test(trimmedCode)) {
return true
}