feat(markdown): 添加 Markdown 渲染组件
- 添加 MarkdownRenderer 组件支持 GFM 语法渲染 - 添加 CodeBlock 组件支持代码块语法高亮 - 集成 Prism.js 实现多语言语法高亮 - 支持代码复制功能
This commit is contained in:
parent
e161da75c7
commit
227a96b232
135
src/components/markdown/CodeBlock.tsx
Normal file
135
src/components/markdown/CodeBlock.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-csharp';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-scss';
|
||||
import 'prismjs/components/prism-markup';
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: string;
|
||||
showLineNumbers?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 语言别名映射
|
||||
const languageAliases: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
yml: 'yaml',
|
||||
html: 'markup',
|
||||
xml: 'markup',
|
||||
svg: 'markup',
|
||||
};
|
||||
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
showLineNumbers = true,
|
||||
className,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 规范化语言名称
|
||||
const normalizedLanguage = languageAliases[language.toLowerCase()] || language.toLowerCase();
|
||||
|
||||
// 使用 useMemo 缓存高亮后的 HTML,避免频繁重新高亮
|
||||
const highlightedCode = useMemo(() => {
|
||||
const grammar = Prism.languages[normalizedLanguage];
|
||||
if (grammar) {
|
||||
return Prism.highlight(code, grammar, normalizedLanguage);
|
||||
}
|
||||
return code;
|
||||
}, [code, normalizedLanguage]);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error('Failed to copy code:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const lines = code.split('\n');
|
||||
|
||||
return (
|
||||
<div className={cn('relative group rounded-lg overflow-hidden my-4', className)}>
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-[#2d2d2d] text-gray-400 text-sm">
|
||||
<span className="font-mono">{language || 'code'}</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded hover:bg-white/10 transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={14} className="text-green-400" />
|
||||
<span className="text-green-400">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={14} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 代码区域 */}
|
||||
<div className="relative overflow-x-auto bg-[#1e1e1e]">
|
||||
{showLineNumbers && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-12 bg-[#1e1e1e] border-r border-gray-700 select-none">
|
||||
<div className="py-4 px-2 text-right">
|
||||
{lines.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-gray-500 text-sm font-mono leading-6"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={cn(
|
||||
'p-4 overflow-x-auto',
|
||||
showLineNumbers && 'pl-16'
|
||||
)}
|
||||
style={{ margin: 0, background: 'transparent' }}
|
||||
>
|
||||
<code
|
||||
className={`language-${normalizedLanguage} text-sm leading-6`}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/components/markdown/MarkdownRenderer.tsx
Normal file
222
src/components/markdown/MarkdownRenderer.tsx
Normal file
@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 将 components 配置提取到组件外部,避免每次渲染时创建新对象
|
||||
const markdownComponents = {
|
||||
// 代码块
|
||||
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !className;
|
||||
|
||||
if (isInline) {
|
||||
// 行内代码
|
||||
return (
|
||||
<code
|
||||
className="bg-red-50 text-red-700 px-1.5 py-0.5 rounded text-sm font-mono"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// 代码块
|
||||
return (
|
||||
<CodeBlock
|
||||
code={String(children).replace(/\n$/, '')}
|
||||
language={match ? match[1] : 'text'}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
// 段落
|
||||
p({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<p className="mb-3 last:mb-0 leading-[1.7]">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
// 标题
|
||||
h1({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h1 className="text-lg font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
},
|
||||
h2({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h2 className="text-base font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-sm font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
h4({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<h4 className="text-sm font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
|
||||
// 列表
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="list-disc pl-5 my-2 space-y-1">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="list-decimal pl-5 my-2 space-y-1">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
li({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<li className="leading-relaxed text-sm">
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
},
|
||||
|
||||
// 链接
|
||||
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-primary)] hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// 粗体
|
||||
strong({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<strong className="font-semibold">
|
||||
{children}
|
||||
</strong>
|
||||
);
|
||||
},
|
||||
|
||||
// 斜体
|
||||
em({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<em className="italic">
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
},
|
||||
|
||||
// 引用
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-sm text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
|
||||
// 分割线
|
||||
hr() {
|
||||
return (
|
||||
<hr className="my-4 border-[var(--color-border)]" />
|
||||
);
|
||||
},
|
||||
|
||||
// 表格
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-3">
|
||||
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-sm">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
thead({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<thead className="bg-[var(--color-bg-tertiary)]">
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
tbody({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
{children}
|
||||
</tbody>
|
||||
);
|
||||
},
|
||||
tr({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<tr className="hover:bg-[var(--color-bg-hover)] transition-colors">
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
th({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<th className="px-3 py-1.5 text-left text-xs font-semibold text-[var(--color-text-primary)]">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
td({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<td className="px-3 py-1.5 text-xs text-[var(--color-text-secondary)]">
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
|
||||
// 图片
|
||||
img({ src, alt }: { src?: string; alt?: string }) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-3"
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// 使用 memo 包裹组件,避免不必要的重渲染
|
||||
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className={cn('markdown-content', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user