|
|
|
@@ -11,7 +11,7 @@ import { |
|
|
|
atelierHeathDark, |
|
|
|
atelierHeathLight, |
|
|
|
} from 'react-syntax-highlighter/dist/esm/styles/hljs' |
|
|
|
import { Component, memo, useMemo, useRef, useState } from 'react' |
|
|
|
import { Component, memo, useEffect, useMemo, useRef, useState } from 'react' |
|
|
|
import { flow } from 'lodash-es' |
|
|
|
import ActionButton from '@/app/components/base/action-button' |
|
|
|
import CopyIcon from '@/app/components/base/copy-icon' |
|
|
|
@@ -74,7 +74,7 @@ const preprocessLaTeX = (content: string) => { |
|
|
|
|
|
|
|
processedContent = flow([ |
|
|
|
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`), |
|
|
|
(str: string) => str.replace(/\\\[(.*?)\\\]/gs, (_, equation) => `$$${equation}$$`), |
|
|
|
(str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`), |
|
|
|
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`), |
|
|
|
(str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`), |
|
|
|
])(processedContent) |
|
|
|
@@ -124,23 +124,143 @@ export function PreCode(props: { children: any }) { |
|
|
|
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => { |
|
|
|
const { theme } = useTheme() |
|
|
|
const [isSVG, setIsSVG] = useState(true) |
|
|
|
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading') |
|
|
|
const [finalChartOption, setFinalChartOption] = useState<any>(null) |
|
|
|
const echartsRef = useRef<any>(null) |
|
|
|
const contentRef = useRef<string>('') |
|
|
|
const processedRef = useRef<boolean>(false) // Track if content was successfully processed |
|
|
|
const match = /language-(\w+)/.exec(className || '') |
|
|
|
const language = match?.[1] |
|
|
|
const languageShowName = getCorrectCapitalizationLanguageName(language || '') |
|
|
|
const chartData = useMemo(() => { |
|
|
|
const str = String(children).replace(/\n$/, '') |
|
|
|
if (language === 'echarts') { |
|
|
|
const isDarkMode = theme === Theme.dark |
|
|
|
|
|
|
|
// Handle container resize for echarts |
|
|
|
useEffect(() => { |
|
|
|
if (language !== 'echarts' || !echartsRef.current) return |
|
|
|
|
|
|
|
const handleResize = () => { |
|
|
|
// This gets the echarts instance from the component |
|
|
|
const instance = echartsRef.current?.getEchartsInstance?.() |
|
|
|
if (instance) |
|
|
|
instance.resize() |
|
|
|
} |
|
|
|
|
|
|
|
window.addEventListener('resize', handleResize) |
|
|
|
|
|
|
|
// Also manually trigger resize after a short delay to ensure proper sizing |
|
|
|
const resizeTimer = setTimeout(handleResize, 200) |
|
|
|
|
|
|
|
return () => { |
|
|
|
window.removeEventListener('resize', handleResize) |
|
|
|
clearTimeout(resizeTimer) |
|
|
|
} |
|
|
|
}, [language, echartsRef.current]) |
|
|
|
|
|
|
|
// Process chart data when content changes |
|
|
|
useEffect(() => { |
|
|
|
// Only process echarts content |
|
|
|
if (language !== 'echarts') return |
|
|
|
|
|
|
|
// Reset state when new content is detected |
|
|
|
if (!contentRef.current) { |
|
|
|
setChartState('loading') |
|
|
|
processedRef.current = false |
|
|
|
} |
|
|
|
|
|
|
|
const newContent = String(children).replace(/\n$/, '') |
|
|
|
|
|
|
|
// Skip if content hasn't changed |
|
|
|
if (contentRef.current === newContent) return |
|
|
|
contentRef.current = newContent |
|
|
|
|
|
|
|
const trimmedContent = newContent.trim() |
|
|
|
if (!trimmedContent) return |
|
|
|
|
|
|
|
// Detect if this is historical data (already complete) |
|
|
|
// Historical data typically comes as a complete code block with complete JSON |
|
|
|
const isCompleteJson |
|
|
|
= (trimmedContent.startsWith('{') && trimmedContent.endsWith('}') |
|
|
|
&& trimmedContent.split('{').length === trimmedContent.split('}').length) |
|
|
|
|| (trimmedContent.startsWith('[') && trimmedContent.endsWith(']') |
|
|
|
&& trimmedContent.split('[').length === trimmedContent.split(']').length) |
|
|
|
|
|
|
|
// If the JSON structure looks complete, try to parse it right away |
|
|
|
if (isCompleteJson && !processedRef.current) { |
|
|
|
try { |
|
|
|
return JSON.parse(str) |
|
|
|
const parsed = JSON.parse(trimmedContent) |
|
|
|
if (typeof parsed === 'object' && parsed !== null) { |
|
|
|
setFinalChartOption(parsed) |
|
|
|
setChartState('success') |
|
|
|
processedRef.current = true |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
catch { } |
|
|
|
catch { |
|
|
|
try { |
|
|
|
// eslint-disable-next-line no-new-func, sonarjs/code-eval |
|
|
|
const result = new Function(`return ${trimmedContent}`)() |
|
|
|
if (typeof result === 'object' && result !== null) { |
|
|
|
setFinalChartOption(result) |
|
|
|
setChartState('success') |
|
|
|
processedRef.current = true |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
catch { |
|
|
|
// If we have a complete JSON structure but it doesn't parse, |
|
|
|
// it's likely an error rather than incomplete data |
|
|
|
setChartState('error') |
|
|
|
processedRef.current = true |
|
|
|
return |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// If we get here, either the JSON isn't complete yet, or we failed to parse it |
|
|
|
// Check more conditions for streaming data |
|
|
|
const isIncomplete |
|
|
|
= trimmedContent.length < 5 |
|
|
|
|| (trimmedContent.startsWith('{') |
|
|
|
&& (!trimmedContent.endsWith('}') |
|
|
|
|| trimmedContent.split('{').length !== trimmedContent.split('}').length)) |
|
|
|
|| (trimmedContent.startsWith('[') |
|
|
|
&& (!trimmedContent.endsWith(']') |
|
|
|
|| trimmedContent.split('[').length !== trimmedContent.split('}').length)) |
|
|
|
|| (trimmedContent.split('"').length % 2 !== 1) |
|
|
|
|| (trimmedContent.includes('{"') && !trimmedContent.includes('"}')) |
|
|
|
|
|
|
|
// Only try to parse streaming data if it looks complete and hasn't been processed |
|
|
|
if (!isIncomplete && !processedRef.current) { |
|
|
|
let isValidOption = false |
|
|
|
|
|
|
|
try { |
|
|
|
// eslint-disable-next-line no-new-func, sonarjs/code-eval |
|
|
|
return new Function(`return ${str}`)() |
|
|
|
const parsed = JSON.parse(trimmedContent) |
|
|
|
if (typeof parsed === 'object' && parsed !== null) { |
|
|
|
setFinalChartOption(parsed) |
|
|
|
isValidOption = true |
|
|
|
} |
|
|
|
} |
|
|
|
catch { |
|
|
|
try { |
|
|
|
// eslint-disable-next-line no-new-func, sonarjs/code-eval |
|
|
|
const result = new Function(`return ${trimmedContent}`)() |
|
|
|
if (typeof result === 'object' && result !== null) { |
|
|
|
setFinalChartOption(result) |
|
|
|
isValidOption = true |
|
|
|
} |
|
|
|
} |
|
|
|
catch { |
|
|
|
// Both parsing methods failed, but content looks complete |
|
|
|
setChartState('error') |
|
|
|
processedRef.current = true |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
if (isValidOption) { |
|
|
|
setChartState('success') |
|
|
|
processedRef.current = true |
|
|
|
} |
|
|
|
catch { } |
|
|
|
} |
|
|
|
return JSON.parse('{"title":{"text":"ECharts error - Wrong option."}}') |
|
|
|
}, [language, children]) |
|
|
|
|
|
|
|
const renderCodeContent = useMemo(() => { |
|
|
|
@@ -150,14 +270,125 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any |
|
|
|
if (isSVG) |
|
|
|
return <Flowchart PrimitiveCode={content} /> |
|
|
|
break |
|
|
|
case 'echarts': |
|
|
|
case 'echarts': { |
|
|
|
// Loading state: show loading indicator |
|
|
|
if (chartState === 'loading') { |
|
|
|
return ( |
|
|
|
<div style={{ |
|
|
|
minHeight: '350px', |
|
|
|
width: '100%', |
|
|
|
display: 'flex', |
|
|
|
flexDirection: 'column', |
|
|
|
alignItems: 'center', |
|
|
|
justifyContent: 'center', |
|
|
|
borderBottomLeftRadius: '10px', |
|
|
|
borderBottomRightRadius: '10px', |
|
|
|
backgroundColor: isDarkMode ? 'var(--color-components-input-bg-normal)' : 'transparent', |
|
|
|
color: 'var(--color-text-secondary)', |
|
|
|
}}> |
|
|
|
<div style={{ |
|
|
|
marginBottom: '12px', |
|
|
|
width: '24px', |
|
|
|
height: '24px', |
|
|
|
}}> |
|
|
|
{/* Rotating spinner that works in both light and dark modes */} |
|
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ animation: 'spin 1.5s linear infinite' }}> |
|
|
|
<style> |
|
|
|
{` |
|
|
|
@keyframes spin { |
|
|
|
0% { transform: rotate(0deg); } |
|
|
|
100% { transform: rotate(360deg); } |
|
|
|
} |
|
|
|
`} |
|
|
|
</style> |
|
|
|
<circle opacity="0.2" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" /> |
|
|
|
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> |
|
|
|
</svg> |
|
|
|
</div> |
|
|
|
<div style={{ |
|
|
|
fontFamily: 'var(--font-family)', |
|
|
|
fontSize: '14px', |
|
|
|
}}>Chart loading...</div> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
// Success state: show the chart |
|
|
|
if (chartState === 'success' && finalChartOption) { |
|
|
|
return ( |
|
|
|
<div style={{ |
|
|
|
minWidth: '300px', |
|
|
|
minHeight: '350px', |
|
|
|
width: '100%', |
|
|
|
overflowX: 'auto', |
|
|
|
borderBottomLeftRadius: '10px', |
|
|
|
borderBottomRightRadius: '10px', |
|
|
|
transition: 'background-color 0.3s ease', |
|
|
|
}}> |
|
|
|
<ErrorBoundary> |
|
|
|
<ReactEcharts |
|
|
|
ref={echartsRef} |
|
|
|
option={finalChartOption} |
|
|
|
style={{ |
|
|
|
height: '350px', |
|
|
|
width: '100%', |
|
|
|
}} |
|
|
|
theme={isDarkMode ? 'dark' : undefined} |
|
|
|
opts={{ |
|
|
|
renderer: 'canvas', |
|
|
|
width: 'auto', |
|
|
|
}} |
|
|
|
notMerge={true} |
|
|
|
onEvents={{ |
|
|
|
// Force resize when chart is finished rendering |
|
|
|
finished: () => { |
|
|
|
const instance = echartsRef.current?.getEchartsInstance?.() |
|
|
|
if (instance) |
|
|
|
instance.resize() |
|
|
|
}, |
|
|
|
}} |
|
|
|
/> |
|
|
|
</ErrorBoundary> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
// Error state: show error message |
|
|
|
const errorOption = { |
|
|
|
title: { |
|
|
|
text: 'ECharts error - Wrong option.', |
|
|
|
}, |
|
|
|
} |
|
|
|
|
|
|
|
return ( |
|
|
|
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}> |
|
|
|
<div style={{ |
|
|
|
minWidth: '300px', |
|
|
|
minHeight: '350px', |
|
|
|
width: '100%', |
|
|
|
overflowX: 'auto', |
|
|
|
borderBottomLeftRadius: '10px', |
|
|
|
borderBottomRightRadius: '10px', |
|
|
|
transition: 'background-color 0.3s ease', |
|
|
|
}}> |
|
|
|
<ErrorBoundary> |
|
|
|
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} /> |
|
|
|
<ReactEcharts |
|
|
|
ref={echartsRef} |
|
|
|
option={errorOption} |
|
|
|
style={{ |
|
|
|
height: '350px', |
|
|
|
width: '100%', |
|
|
|
}} |
|
|
|
theme={isDarkMode ? 'dark' : undefined} |
|
|
|
opts={{ |
|
|
|
renderer: 'canvas', |
|
|
|
width: 'auto', |
|
|
|
}} |
|
|
|
notMerge={true} |
|
|
|
/> |
|
|
|
</ErrorBoundary> |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
case 'svg': |
|
|
|
if (isSVG) { |
|
|
|
return ( |
|
|
|
@@ -192,7 +423,7 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any |
|
|
|
</SyntaxHighlighter> |
|
|
|
) |
|
|
|
} |
|
|
|
}, [children, language, isSVG, chartData, props, theme, match]) |
|
|
|
}, [children, language, isSVG, finalChartOption, props, theme, match]) |
|
|
|
|
|
|
|
if (inline || !match) |
|
|
|
return <code {...props} className={className}>{children}</code> |