MarkdownViewer.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import React, { useEffect, useMemo, useRef, useState } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { BulbOutlined, InfoCircleOutlined, CheckCircleOutlined, ExclamationCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
  4. import ReactMarkdown from 'react-markdown';
  5. import rehypeRaw from 'rehype-raw';
  6. export type TocItem = { id: string; text: string; level: number };
  7. function slugify(text: string) {
  8. return text
  9. .trim()
  10. .replace(/\s+/g, '-')
  11. .replace(/[()():/??、,.。!!'"`]/g, '')
  12. .toLowerCase();
  13. }
  14. interface MarkdownViewerProps {
  15. content: string;
  16. onToc?: (toc: TocItem[]) => void;
  17. }
  18. const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content, onToc }) => {
  19. const [toc, setToc] = useState<TocItem[]>([]);
  20. const containerRef = useRef<HTMLDivElement | null>(null);
  21. const navigate = useNavigate();
  22. const components = useMemo(() => {
  23. const getNodeText = (node: any): string => {
  24. if (node == null) return '';
  25. if (typeof node === 'string' || typeof node === 'number') return String(node);
  26. if (Array.isArray(node)) return node.map(getNodeText).join('');
  27. if (node.props && node.props.children) return getNodeText(node.props.children);
  28. return '';
  29. };
  30. const Heading = (level: number) =>
  31. function H(props: any) {
  32. const childrenText = getNodeText(props.children);
  33. const id = slugify(childrenText);
  34. return React.createElement(
  35. 'h' + level,
  36. { id },
  37. props.children
  38. );
  39. };
  40. const isExternal = (href?: string) => !!href && (/^https?:\/\//i.test(href) || href.startsWith('//'));
  41. const handleLinkClick = (e: React.MouseEvent<HTMLAnchorElement>, href?: string) => {
  42. if (!href) return;
  43. if (href.startsWith('#')) {
  44. e.preventDefault();
  45. const id = href.slice(1);
  46. const el = document.getElementById(id);
  47. if (el) {
  48. el.scrollIntoView({ behavior: 'smooth', block: 'start' });
  49. history.replaceState(null, '', `#${id}`);
  50. }
  51. return;
  52. }
  53. if (href.startsWith('/help')) {
  54. e.preventDefault();
  55. navigate(href);
  56. return;
  57. }
  58. };
  59. return {
  60. h1: Heading(1),
  61. h2: Heading(2),
  62. h3: Heading(3),
  63. table: (props: any) => (
  64. <div style={{ overflowX: 'auto' }}>
  65. <table {...props} />
  66. </div>
  67. ),
  68. code: (props: any) => (
  69. <code style={{ background: '#f6f7f9', padding: '2px 4px', borderRadius: 4 }} {...props} />
  70. ),
  71. a: (props: any) => {
  72. const href = props?.href as string | undefined;
  73. const external = isExternal(href);
  74. return (
  75. <a
  76. {...props}
  77. target={external ? '_blank' : undefined}
  78. rel={external ? 'noreferrer' : undefined}
  79. onClick={(e) => {
  80. if (!external) handleLinkClick(e, href);
  81. props?.onClick?.(e);
  82. }}
  83. />
  84. );
  85. },
  86. img: (props: any) => (
  87. <img
  88. style={{ maxWidth: '100%', height: 'auto', display: 'block', margin: '12px auto' }}
  89. {...props}
  90. />
  91. ),
  92. // Custom: <Tip type="info|warn|success">...children...</Tip>
  93. tip: (props: any) => {
  94. const type = props?.node?.properties?.type || 'info';
  95. const iconName = props?.node?.properties?.['data-icon'];
  96. const colorMap: Record<string, { bg: string; bd: string; text: string }> = {
  97. info: { bg: '#f5f9ff', bd: '#d6e4ff', text: '#5b6b8c' },
  98. warn: { bg: '#fff9f0', bd: '#ffe58f', text: '#ad6800' },
  99. success: { bg: '#f6fef9', bd: '#d1fadf', text: '#046c4e' },
  100. danger: { bg: '#fff1f0', bd: '#ffa39e', text: '#a8071a' },
  101. };
  102. const theme = colorMap[type] || colorMap.info;
  103. const iconMap: Record<string, React.ReactNode> = {
  104. bulb: <BulbOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  105. info: <InfoCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  106. check: <CheckCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  107. success: <CheckCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  108. warning: <ExclamationCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  109. warn: <ExclamationCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  110. danger: <CloseCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
  111. };
  112. const defaultIconByType: Record<string, React.ReactNode> = {
  113. info: iconMap.info,
  114. warn: iconMap.warning,
  115. success: iconMap.success,
  116. danger: iconMap.danger,
  117. };
  118. const iconNode = iconName ? (iconMap[iconName] || defaultIconByType[type]) : defaultIconByType[type] || iconMap.bulb;
  119. return (
  120. <div className={`help-tip help-tip-${type}`}
  121. style={{
  122. background: theme.bg,
  123. border: `1px solid ${theme.bd}`,
  124. color: theme.text,
  125. padding: '12px 14px',
  126. borderRadius: 8,
  127. lineHeight: 1.6,
  128. margin: '12px 0',
  129. }}
  130. >
  131. <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
  132. {iconNode}
  133. <div style={{ flex: 1 }}>{props.children}</div>
  134. </div>
  135. </div>
  136. );
  137. },
  138. // Custom: <CardGroup cols="3"> <Card ...>...</Card> ... </CardGroup>
  139. cardgroup: (props: any) => {
  140. const colsProp = props?.node?.properties?.cols;
  141. const cols = Number(colsProp) > 0 ? Number(colsProp) : 3;
  142. const grid = `repeat(${cols}, minmax(0, 1fr))`;
  143. return (
  144. <div className="help-card-group" style={{ display: 'grid', gap: 16, gridTemplateColumns: grid, margin: '12px 0' }}>
  145. {props.children}
  146. </div>
  147. );
  148. },
  149. // Custom: <Card title="" href="/path" data-icon="book-open">children</Card>
  150. card: (props: any) => {
  151. const p = props?.node?.properties || {};
  152. const title = p.title || '';
  153. const href = p.href || '';
  154. const icon = p['data-icon'] || '';
  155. const iconStyle: React.CSSProperties = icon
  156. ? {
  157. WebkitMaskImage: `url(/resource/icon/${icon}.svg)`,
  158. maskImage: `url(/resource/icon/${icon}.svg)`,
  159. WebkitMaskRepeat: 'no-repeat',
  160. maskRepeat: 'no-repeat',
  161. WebkitMaskPosition: 'center',
  162. maskPosition: 'center',
  163. background: '#1677ff',
  164. width: 24,
  165. height: 24,
  166. display: 'inline-block',
  167. }
  168. : {};
  169. const content = (
  170. <div className="help-card" style={{
  171. border: '1px solid #eee',
  172. borderRadius: 10,
  173. padding: 16,
  174. background: '#fff',
  175. boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
  176. height: '100%',
  177. }}>
  178. <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
  179. {icon ? <span style={iconStyle} /> : null}
  180. <div style={{ fontWeight: 600 }}>{title}</div>
  181. </div>
  182. <div className="help-card-content" style={{ color: '#555' }}>{props.children}</div>
  183. </div>
  184. );
  185. if (!href) return content;
  186. const external = isExternal(href);
  187. return (
  188. <a
  189. href={href}
  190. style={{ textDecoration: 'none' }}
  191. target={external ? '_blank' : undefined}
  192. rel={external ? 'noreferrer' : undefined}
  193. onClick={(e) => {
  194. if (!external) handleLinkClick(e, href);
  195. }}
  196. >
  197. {content}
  198. </a>
  199. );
  200. },
  201. } as any;
  202. }, []);
  203. useEffect(() => {
  204. // build toc after render
  205. const root = containerRef.current;
  206. if (!root) return;
  207. const nodes = Array.from(root.querySelectorAll('h1, h2, h3')) as HTMLElement[];
  208. const items = nodes.map((n) => ({ id: n.id, text: n.textContent || '', level: Number(n.tagName.substring(1)) }));
  209. setToc(items);
  210. onToc?.(items);
  211. }, [content, onToc]);
  212. return (
  213. <div ref={containerRef} className="help-markdown">
  214. {/* Ensure raw HTML elements styled as documentation site */}
  215. <style>{`
  216. .help-markdown img{max-width:100%;height:auto;display:block;margin:12px auto;}
  217. .help-markdown figure{margin:16px auto;max-width:100%;}
  218. .help-markdown figure img{display:block;margin:0 auto;}
  219. .help-markdown figcaption{margin-top:8px;color:#666;font-size:12px;text-align:center;}
  220. .help-card-group{--gap:16px}
  221. .help-card a{color:inherit}
  222. /* Typography */
  223. .help-markdown h1{font-size:28px;line-height:1.35;font-weight:700;margin:32px 0 12px;}
  224. .help-markdown h2{font-size:24px;line-height:1.4;font-weight:700;margin:28px 0 12px;}
  225. .help-markdown h3{font-size:20px;line-height:1.45;font-weight:600;margin:24px 0 8px;}
  226. .help-markdown p{font-size:15.5px;line-height:1.9;margin:10px 0 16px;}
  227. .help-markdown ul,.help-markdown ol{padding-left:1.4em;margin:8px 0 16px;}
  228. .help-markdown li{margin:4px 0;}
  229. .help-markdown strong{font-weight:700;}
  230. .help-markdown blockquote{border-left:3px solid #e6f4ff;background:#f7fbff;padding:8px 12px;color:#334155;margin:12px 0;}
  231. /* Success Tip showcase style */
  232. .help-tip-success{border-radius:14px;padding:16px 18px;border-color:#d1fadf;background:#f6fef9;box-shadow:0 2px 8px rgba(4,108,78,0.04) inset, 0 1px 2px rgba(0,0,0,0.02)}
  233. .help-tip-success a{color:#0f766e}
  234. .help-tip-success img{border-radius:12px;box-shadow:0 10px 24px rgba(2,44,34,0.08);}
  235. `}</style>
  236. <ReactMarkdown rehypePlugins={[rehypeRaw]} components={components}>
  237. {content}
  238. </ReactMarkdown>
  239. </div>
  240. );
  241. };
  242. export default MarkdownViewer;