<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { marked } from 'marked'
import markedKatex from 'marked-katex-extension'
import hljs from 'highlight.js'
import DOMPurify from 'dompurify'
import mermaid from 'mermaid'
import * as echarts from 'echarts'
import { useAppStore } from '@/stores/appStore'
import 'highlight.js/styles/github.css' import 'katex/dist/katex.min.css'
const props = defineProps<{
source?: string | null
placeholder?: string
small?: boolean
}>()
const store = useAppStore()
const renderedContent = ref('')
const isLoading = ref(false)
const containerRef = ref<HTMLElement | null>(null)
let isMounted = false
let charts: echarts.ECharts[] = []
const lightPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'
]
mermaid.initialize({
startOnLoad: false,
theme: 'neutral', securityLevel: 'loose',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
})
const renderer = new marked.Renderer()
renderer.code = ({ text, lang }: { text: string, lang?: string }) => {
if (lang === 'mermaid') {
return `<div class="mermaid">${text}</div>`
}
if (lang === 'echarts') {
return `<div class="echarts-container w-full h-[300px]" data-option="${encodeURIComponent(text)}"></div>`
}
const language = (lang && hljs.getLanguage(lang)) ? lang : 'plaintext'
try {
const highlighted = hljs.highlight(text, { language }).value
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`
} catch (e) {
return `<pre><code class="hljs language-${language}">${text}</code></pre>`
}
}
marked.use(
{ renderer },
markedKatex({
throwOnError: false
})
)
onMounted(() => {
isMounted = true
})
onUnmounted(() => {
isMounted = false
disposeCharts()
})
let resizeObservers: ResizeObserver[] = []
function disposeCharts() {
resizeObservers.forEach(ro => ro.disconnect())
resizeObservers = []
charts.forEach(c => c.dispose())
charts = []
}
function migrateOptions(option: any) {
if (!option || typeof option !== 'object') return
if (option.itemStyle && option.itemStyle.emphasis) {
if (!option.emphasis) option.emphasis = {}
option.emphasis.itemStyle = option.itemStyle.emphasis
delete option.itemStyle.emphasis
}
for (const key in option) {
if (Array.isArray(option[key])) {
option[key].forEach((item: any) => migrateOptions(item))
} else if (typeof option[key] === 'object') {
migrateOptions(option[key])
}
}
}
async function renderDiagrams() {
if (!containerRef.value) return
try {
await mermaid.run({
nodes: containerRef.value.querySelectorAll('.mermaid')
})
} catch (e) {
console.error('Mermaid render failed:', e)
}
disposeCharts()
const echartsContainers = containerRef.value.querySelectorAll('.echarts-container')
echartsContainers.forEach((el) => {
try {
const optionStr = decodeURIComponent(el.getAttribute('data-option') || '')
const option = JSON.parse(optionStr)
migrateOptions(option)
if (!option.textStyle) option.textStyle = {}
option.textStyle.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Heiti SC", monospace'
const isDark = store.isCyberpunkMode
if (!option.backgroundColor) {
option.backgroundColor = 'transparent'
}
if (!isDark && !option.color) {
option.color = lightPalette
}
const theme = isDark ? 'dark' : undefined
const chart = echarts.init(el as HTMLElement, theme, { renderer: 'svg' })
chart.setOption(option)
charts.push(chart)
const ro = new ResizeObserver(() => {
if (!chart.isDisposed()) {
chart.resize()
}
})
ro.observe(el)
resizeObservers.push(ro)
} catch (e) {
console.error('Echarts render failed:', e)
el.innerHTML = `<div class="text-sci-danger text-xs font-mono p-2 border border-sci-danger rounded">Echarts Error: Invalid JSON</div>`
}
})
}
watch(() => store.isCyberpunkMode, () => {
renderDiagrams()
})
watch(() => props.source, async (newSource) => {
if (!newSource) {
renderedContent.value = ''
return
}
isLoading.value = true
try {
const result = await marked.parse(newSource)
if (!isMounted) return
if (typeof result === 'string') {
renderedContent.value = DOMPurify.sanitize(result, {
ADD_TAGS: ['div', 'input', 'video', 'audio', 'iframe', 'source'], ADD_ATTR: ['class', 'data-option', 'style', 'type', 'checked', 'disabled', 'src', 'controls', 'width', 'height', 'allow', 'allowfullscreen']
})
nextTick(() => {
renderDiagrams()
})
} else {
console.error('marked.parse returned non-string:', result)
renderedContent.value = newSource
}
} catch (e) {
console.error('Markdown parsing failed:', e)
if (isMounted) renderedContent.value = newSource } finally {
if (isMounted) isLoading.value = false
}
}, { immediate: true })
</script>
<template>
<div
ref="containerRef"
class="prose max-w-none text-sci-text-sec break-words relative min-h-[1em]"
:class="[
small ? 'prose-xs' : 'prose-sm'
]"
>
<div v-if="isLoading" class="absolute inset-0 bg-sci-base/50 flex items-center justify-center z-10">
<div class="w-4 h-4 border-2 border-sci-cyan border-t-transparent rounded-full animate-spin"></div>
</div>
<div v-show="renderedContent" v-html="renderedContent"></div>
<div v-show="!renderedContent && source && !isLoading" class="whitespace-pre-wrap font-mono text-xs text-sci-danger">
[RENDER FAILED]
{{ source }}
</div>
<div v-show="!renderedContent && !source && placeholder" class="text-sci-text-dim italic">{{ placeholder }}</div>
<div v-show="!renderedContent && !source && !placeholder" class="opacity-0">.</div>
</div>
</template>
<style>
.prose pre, .prose code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace, "Microsoft YaHei", "PingFang SC", "Hiragino Sans GB", "Heiti SC" !important;
}
.prose pre {
background-color: #f6f8fa !important;
border: 1px solid #e1e4e8;
border-radius: 0.375rem;
padding: 1em;
margin-top: 1em;
margin-bottom: 1em;
overflow-x: auto;
color: #24292e;
}
.prose code {
font-size: 0.9em;
}
.prose-xs h1 { font-size: 1.1em; margin-top: 0.8em; margin-bottom: 0.4em; }
.prose-xs h2 { font-size: 1em; margin-top: 0.8em; margin-bottom: 0.4em; }
.prose-xs h3 { font-size: 0.9em; margin-top: 0.6em; margin-bottom: 0.3em; }
.prose-xs h4 { font-size: 0.85em; margin-top: 0.6em; margin-bottom: 0.3em; }
.prose-xs p { margin-top: 0.5em; margin-bottom: 0.5em; }
.prose-xs ul, .prose-xs ol { margin-top: 0.5em; margin-bottom: 0.5em; }
.prose-xs li { margin-top: 0.2em; margin-bottom: 0.2em; }
</style>