import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { BulbOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; export type TocItem = { id: string; text: string; level: number }; function slugify(text: string) { return text .trim() .replace(/\s+/g, '-') .replace(/[()():/??、,.。!!'"`]/g, '') .toLowerCase(); } interface MarkdownViewerProps { content: string; onToc?: (toc: TocItem[]) => void; } const MarkdownViewer: React.FC = ({ content, onToc }) => { const [toc, setToc] = useState([]); const containerRef = useRef(null); const navigate = useNavigate(); const components = useMemo(() => { const getNodeText = (node: any): string => { if (node == null) return ''; if (typeof node === 'string' || typeof node === 'number') return String(node); if (Array.isArray(node)) return node.map(getNodeText).join(''); if (node.props && node.props.children) return getNodeText(node.props.children); return ''; }; const Heading = (level: number) => function H(props: any) { const childrenText = getNodeText(props.children); const id = slugify(childrenText); return React.createElement( 'h' + level, { id }, props.children ); }; const isExternal = (href?: string) => !!href && (/^https?:\/\//i.test(href) || href.startsWith('//')); const handleLinkClick = (e: React.MouseEvent, href?: string) => { if (!href) return; if (href.startsWith('#')) { e.preventDefault(); const id = href.slice(1); const el = document.getElementById(id); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); history.replaceState(null, '', `#${id}`); } return; } if (href.startsWith('/help')) { e.preventDefault(); navigate(href); return; } }; return { h1: Heading(1), h2: Heading(2), h3: Heading(3), table: (props: any) => (
), code: (props: any) => ( ), a: (props: any) => { const href = props?.href as string | undefined; const external = isExternal(href); return ( { if (!external) handleLinkClick(e, href); props?.onClick?.(e); }} /> ); }, img: (props: any) => ( ), // Custom: ...children... tip: (props: any) => { const type = props?.node?.properties?.type || 'info'; const iconName = props?.node?.properties?.['data-icon']; const colorMap: Record = { info: { bg: '#f5f9ff', bd: '#d6e4ff', text: '#5b6b8c' }, warn: { bg: '#fff9f0', bd: '#ffe58f', text: '#ad6800' }, success: { bg: '#f6fef9', bd: '#d1fadf', text: '#046c4e' }, danger: { bg: '#fff1f0', bd: '#ffa39e', text: '#a8071a' }, }; const theme = colorMap[type] || colorMap.info; const iconMap: Record = { bulb: , info: , check: , success: , warning: , warn: , danger: , }; const defaultIconByType: Record = { info: iconMap.info, warn: iconMap.warning, success: iconMap.success, danger: iconMap.danger, }; const iconNode = iconName ? (iconMap[iconName] || defaultIconByType[type]) : defaultIconByType[type] || iconMap.bulb; return (
{iconNode}
{props.children}
); }, // Custom: ... ... cardgroup: (props: any) => { const colsProp = props?.node?.properties?.cols; const cols = Number(colsProp) > 0 ? Number(colsProp) : 3; const grid = `repeat(${cols}, minmax(0, 1fr))`; return (
{props.children}
); }, // Custom: children card: (props: any) => { const p = props?.node?.properties || {}; const title = p.title || ''; const href = p.href || ''; const icon = p['data-icon'] || ''; const iconStyle: React.CSSProperties = icon ? { WebkitMaskImage: `url(/resource/icon/${icon}.svg)`, maskImage: `url(/resource/icon/${icon}.svg)`, WebkitMaskRepeat: 'no-repeat', maskRepeat: 'no-repeat', WebkitMaskPosition: 'center', maskPosition: 'center', background: '#1677ff', width: 24, height: 24, display: 'inline-block', } : {}; const content = (
{icon ? : null}
{title}
{props.children}
); if (!href) return content; const external = isExternal(href); return (
{ if (!external) handleLinkClick(e, href); }} > {content} ); }, } as any; }, []); useEffect(() => { // build toc after render const root = containerRef.current; if (!root) return; const nodes = Array.from(root.querySelectorAll('h1, h2, h3')) as HTMLElement[]; const items = nodes.map((n) => ({ id: n.id, text: n.textContent || '', level: Number(n.tagName.substring(1)) })); setToc(items); onToc?.(items); }, [content, onToc]); return (
{/* Ensure raw HTML elements styled as documentation site */} {content}
); }; export default MarkdownViewer;