|
|
@@ -1,4 +1,6 @@
|
|
|
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';
|
|
|
|
|
|
@@ -20,6 +22,7 @@ interface MarkdownViewerProps {
|
|
|
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 => {
|
|
|
@@ -40,6 +43,26 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content, onToc }) => {
|
|
|
);
|
|
|
};
|
|
|
|
|
|
+ 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),
|
|
|
@@ -52,13 +75,136 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content, onToc }) => {
|
|
|
code: (props: any) => (
|
|
|
<code style={{ background: '#f6f7f9', padding: '2px 4px', borderRadius: 4 }} {...props} />
|
|
|
),
|
|
|
- a: (props: any) => <a target="_blank" rel="noreferrer" {...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;
|
|
|
}, []);
|
|
|
|
|
|
@@ -80,6 +226,21 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content, onToc }) => {
|
|
|
.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}
|