| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- 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<MarkdownViewerProps> = ({ content, onToc }) => {
- const [toc, setToc] = useState<TocItem[]>([]);
- const containerRef = useRef<HTMLDivElement | null>(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<HTMLAnchorElement>, 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) => (
- <div style={{ overflowX: 'auto' }}>
- <table {...props} />
- </div>
- ),
- code: (props: any) => (
- <code style={{ background: '#f6f7f9', padding: '2px 4px', borderRadius: 4 }} {...props} />
- ),
- a: (props: any) => {
- const href = props?.href as string | undefined;
- const external = isExternal(href);
- return (
- <a
- {...props}
- target={external ? '_blank' : undefined}
- rel={external ? 'noreferrer' : undefined}
- onClick={(e) => {
- if (!external) handleLinkClick(e, href);
- props?.onClick?.(e);
- }}
- />
- );
- },
- img: (props: any) => (
- <img
- style={{ maxWidth: '100%', height: 'auto', display: 'block', margin: '12px auto' }}
- {...props}
- />
- ),
- // Custom: <Tip type="info|warn|success">...children...</Tip>
- tip: (props: any) => {
- const type = props?.node?.properties?.type || 'info';
- const iconName = props?.node?.properties?.['data-icon'];
- const colorMap: Record<string, { bg: string; bd: string; text: string }> = {
- 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<string, React.ReactNode> = {
- bulb: <BulbOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- info: <InfoCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- check: <CheckCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- success: <CheckCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- warning: <ExclamationCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- warn: <ExclamationCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- danger: <CloseCircleOutlined style={{ fontSize: 18, color: theme.text, marginTop: 2 }} />,
- };
- const defaultIconByType: Record<string, React.ReactNode> = {
- info: iconMap.info,
- warn: iconMap.warning,
- success: iconMap.success,
- danger: iconMap.danger,
- };
- const iconNode = iconName ? (iconMap[iconName] || defaultIconByType[type]) : defaultIconByType[type] || iconMap.bulb;
- return (
- <div className={`help-tip help-tip-${type}`}
- style={{
- background: theme.bg,
- border: `1px solid ${theme.bd}`,
- color: theme.text,
- padding: '12px 14px',
- borderRadius: 8,
- lineHeight: 1.6,
- margin: '12px 0',
- }}
- >
- <div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
- {iconNode}
- <div style={{ flex: 1 }}>{props.children}</div>
- </div>
- </div>
- );
- },
- // Custom: <CardGroup cols="3"> <Card ...>...</Card> ... </CardGroup>
- 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 (
- <div className="help-card-group" style={{ display: 'grid', gap: 16, gridTemplateColumns: grid, margin: '12px 0' }}>
- {props.children}
- </div>
- );
- },
- // Custom: <Card title="" href="/path" data-icon="book-open">children</Card>
- 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 = (
- <div className="help-card" style={{
- border: '1px solid #eee',
- borderRadius: 10,
- padding: 16,
- background: '#fff',
- boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
- height: '100%',
- }}>
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
- {icon ? <span style={iconStyle} /> : null}
- <div style={{ fontWeight: 600 }}>{title}</div>
- </div>
- <div className="help-card-content" style={{ color: '#555' }}>{props.children}</div>
- </div>
- );
- if (!href) return content;
- const external = isExternal(href);
- return (
- <a
- href={href}
- style={{ textDecoration: 'none' }}
- target={external ? '_blank' : undefined}
- rel={external ? 'noreferrer' : undefined}
- onClick={(e) => {
- if (!external) handleLinkClick(e, href);
- }}
- >
- {content}
- </a>
- );
- },
- } 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 (
- <div ref={containerRef} className="help-markdown">
- {/* Ensure raw HTML elements styled as documentation site */}
- <style>{`
- .help-markdown img{max-width:100%;height:auto;display:block;margin:12px auto;}
- .help-markdown figure{margin:16px auto;max-width:100%;}
- .help-markdown figure img{display:block;margin:0 auto;}
- .help-markdown figcaption{margin-top:8px;color:#666;font-size:12px;text-align:center;}
- .help-card-group{--gap:16px}
- .help-card a{color:inherit}
- /* Typography */
- .help-markdown h1{font-size:28px;line-height:1.35;font-weight:700;margin:32px 0 12px;}
- .help-markdown h2{font-size:24px;line-height:1.4;font-weight:700;margin:28px 0 12px;}
- .help-markdown h3{font-size:20px;line-height:1.45;font-weight:600;margin:24px 0 8px;}
- .help-markdown p{font-size:15.5px;line-height:1.9;margin:10px 0 16px;}
- .help-markdown ul,.help-markdown ol{padding-left:1.4em;margin:8px 0 16px;}
- .help-markdown li{margin:4px 0;}
- .help-markdown strong{font-weight:700;}
- .help-markdown blockquote{border-left:3px solid #e6f4ff;background:#f7fbff;padding:8px 12px;color:#334155;margin:12px 0;}
- /* Success Tip showcase style */
- .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)}
- .help-tip-success a{color:#0f766e}
- .help-tip-success img{border-radius:12px;box-shadow:0 10px 24px rgba(2,44,34,0.08);}
- `}</style>
- <ReactMarkdown rehypePlugins={[rehypeRaw]} components={components}>
- {content}
- </ReactMarkdown>
- </div>
- );
- };
- export default MarkdownViewer;
|