From 227a96b23230c2d93beead40e0b9425bf7b6cd6a Mon Sep 17 00:00:00 2001 From: gaoziman <2942894660@qq.com> Date: Thu, 18 Dec 2025 11:29:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(markdown):=20=E6=B7=BB=E5=8A=A0=20Markdown?= =?UTF-8?q?=20=E6=B8=B2=E6=9F=93=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 MarkdownRenderer 组件支持 GFM 语法渲染 - 添加 CodeBlock 组件支持代码块语法高亮 - 集成 Prism.js 实现多语言语法高亮 - 支持代码复制功能 --- src/components/markdown/CodeBlock.tsx | 135 +++++++++++ src/components/markdown/MarkdownRenderer.tsx | 222 +++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 src/components/markdown/CodeBlock.tsx create mode 100644 src/components/markdown/MarkdownRenderer.tsx diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx new file mode 100644 index 0000000..53911ef --- /dev/null +++ b/src/components/markdown/CodeBlock.tsx @@ -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 = { + 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 ( +
+ {/* 顶部工具栏 */} +
+ {language || 'code'} + +
+ + {/* 代码区域 */} +
+ {showLineNumbers && ( +
+
+ {lines.map((_, index) => ( +
+ {index + 1} +
+ ))} +
+
+ )} +
+          
+        
+
+
+ ); +} diff --git a/src/components/markdown/MarkdownRenderer.tsx b/src/components/markdown/MarkdownRenderer.tsx new file mode 100644 index 0000000..6d12969 --- /dev/null +++ b/src/components/markdown/MarkdownRenderer.tsx @@ -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 ( + + {children} + + ); + } + + // 代码块 + return ( + + ); + }, + + // 段落 + p({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + + // 标题 + h1({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h2({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h3({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + h4({ children }: { children?: React.ReactNode }) { + return ( +

+ {children} +

+ ); + }, + + // 列表 + ul({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
+ ); + }, + ol({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
+ ); + }, + li({ children }: { children?: React.ReactNode }) { + return ( +
  • + {children} +
  • + ); + }, + + // 链接 + a({ href, children }: { href?: string; children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 粗体 + strong({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 斜体 + em({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 引用 + blockquote({ children }: { children?: React.ReactNode }) { + return ( +
    + {children} +
    + ); + }, + + // 分割线 + hr() { + return ( +
    + ); + }, + + // 表格 + table({ children }: { children?: React.ReactNode }) { + return ( +
    + + {children} +
    +
    + ); + }, + thead({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + tbody({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + tr({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + th({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + td({ children }: { children?: React.ReactNode }) { + return ( + + {children} + + ); + }, + + // 图片 + img({ src, alt }: { src?: string; alt?: string }) { + return ( + {alt + ); + }, +}; + +// 使用 memo 包裹组件,避免不必要的重渲染 +export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className }: MarkdownRendererProps) { + return ( +
    + + {content} + +
    + ); +});