feat(markdown): 添加 Markdown 渲染组件

- 添加 MarkdownRenderer 组件支持 GFM 语法渲染
- 添加 CodeBlock 组件支持代码块语法高亮
- 集成 Prism.js 实现多语言语法高亮
- 支持代码复制功能
This commit is contained in:
gaoziman 2025-12-18 11:29:19 +08:00
parent e161da75c7
commit 227a96b232
2 changed files with 357 additions and 0 deletions

View 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>
);
}

View 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>
);
});