mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 10:29:02 +08:00
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:
parent
b53a5aa3af
commit
33ec5c5c6b
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }>`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { default as HtmlArtifactsCard } from './HtmlArtifactsCard'
|
||||
export * from './types'
|
||||
export * from './view'
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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', () => {
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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>
|
||||
`;
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user