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