Commit 778f325d by Liu

feat:流式输出时展示饼图

parent 3f5bbff0
......@@ -87,3 +87,6 @@ _fixtures
src/typegen.d.ts
.eslint-config-inspector
# pnpm local store (should not be committed)
.pnpm-store/
......@@ -48,6 +48,7 @@
"date-fns": "^3.6.0",
"docx-preview": "^0.3.6",
"github-markdown-css": "^5.8.0",
"mermaid": "^11.13.0",
"pdfjs-dist": "^5.4.149",
"react": "^18.3.1",
"react-dom": "^18.3.1",
......
import { type ReactNode, memo } from 'react'
import { type ReactNode, isValidElement, memo, useEffect, useId, useMemo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import { PhotoProvider, PhotoView } from 'react-photo-view'
import { Image } from '@heroui/react'
......@@ -10,13 +10,157 @@ interface MarkdownDetailProps {
children: ReactNode
}
const sanitizeSchema = {
...defaultSchema,
attributes: {
...(defaultSchema as any).attributes,
a: [...(((defaultSchema as any).attributes?.a ?? []) as any[]), 'target', 'rel'],
},
}
function toText(node: any): string {
if (node == null)
return ''
if (typeof node === 'string')
return node
if (Array.isArray(node))
return node.map(toText).join('')
return ''
}
function normalizeMermaid(code: string): string {
const raw = String(code ?? '')
.replace(/\r\n/g, '\n')
.replace(/\u00A0/g, ' ')
.replace(/[\u2000-\u200B\u202F\u205F\u3000]/g, ' ')
.trim()
const lines = raw
.split('\n')
.map(l => l.trim().replace(/。$/g, ''))
.filter(Boolean)
return lines.join('\n').trim()
}
let mermaidInitPromise: Promise<any> | null = null
async function getMermaid() {
if (!mermaidInitPromise) {
mermaidInitPromise = (async () => {
const mod: any = await import('mermaid')
const mermaid = mod?.default ?? mod
if (!mermaid)
throw new Error('Mermaid not available')
mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true })
return mermaid
})()
}
return mermaidInitPromise
}
function MermaidBlock({ chart }: { chart: string }) {
const reactId = useId()
const renderId = useMemo(() => `mermaid-${reactId.replace(/:/g, '-')}`, [reactId])
const [svg, setSvg] = useState<string>('')
const [hidden, setHidden] = useState<boolean>(false)
useEffect(() => {
let cancelled = false
const code = normalizeMermaid(chart)
if (!code) {
setSvg('')
setHidden(true)
return () => {
cancelled = true
}
}
;(async () => {
try {
const mermaid = await getMermaid()
mermaid.initialize({ startOnLoad: false, suppressErrorRendering: true })
try {
await mermaid.parse(code)
}
catch {
if (!cancelled) {
setSvg('')
setHidden(true)
}
return
}
const res = await mermaid.render(renderId, code)
if (!cancelled) {
const outSvg = String(res?.svg || '')
if (/Syntax error in text/i.test(outSvg)) {
setSvg('')
setHidden(true)
}
else {
setSvg(outSvg)
setHidden(false)
}
}
}
catch {
if (!cancelled) {
setSvg('')
setHidden(true)
}
}
})()
return () => {
cancelled = true
}
}, [chart, renderId])
if (hidden || !svg)
return null
return (
<div className="my-[8px] overflow-x-auto">
<div dangerouslySetInnerHTML={{ __html: svg }} />
</div>
)
}
export const MarkdownDetail: React.FC<MarkdownDetailProps> = memo(({ children }) => {
return (
<ReactMarkdown
rehypePlugins={[rehypeRaw, rehypeSanitize]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema as any]]}
remarkPlugins={[remarkGfm]}
className="markdown-body flex flex-col text-[#27353C] text-[15px]"
components={{
a: ({ href, children, ...props }) => {
const link = String(href || '').trim()
const isHashLink = link.startsWith('#')
const isExternal = /^https?:\/\//i.test(link) || link.startsWith('//')
return (
<a
{...props}
href={href}
target={(link && isExternal && !isHashLink) ? '_blank' : undefined}
rel={(link && isExternal && !isHashLink) ? 'noopener noreferrer' : undefined}
>
{children}
</a>
)
},
pre: ({ children, ...props }) => {
const arr = Array.isArray(children) ? children : [children]
const first = arr.find(Boolean)
if (isValidElement(first)) {
const className = String((first.props as any)?.className || '')
const lang = className.match(/language-(\S+)/)?.[1]?.toLowerCase()
if (lang?.startsWith('mermaid') || lang === 'pie') {
const chart = toText((first.props as any)?.children)
return <MermaidBlock chart={chart} />
}
}
return <pre {...props}>{children}</pre>
},
img: ({ ...data }): JSX.Element => {
return (
<PhotoProvider maskOpacity={0.5} bannerVisible={false}>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment