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