1+ "use client" ;
2+
3+ import React , { type DetailedHTMLProps , type HTMLAttributes , useEffect , useMemo , useRef , useState } from "react" ;
4+ import { Copy , Check } from "lucide-react" ;
15import ReactMarkdown from 'react-markdown' ;
26import remarkGfm from 'remark-gfm' ;
37import remarkBreaks from 'remark-breaks' ;
48import remarkMath from 'remark-math' ;
59import rehypePrism from 'rehype-prism-plus' ;
610import rehypeKatex from 'rehype-katex' ;
711
12+ import { cn , copyToClipboard } from "@/lib/utils" ;
13+
814import './github-markdown.css' ;
915import './prism-ghcolors-auto.css' ;
1016import "katex/dist/katex.min.css" ;
@@ -24,6 +30,83 @@ function NormalizeMathTags(input: string): string {
2430 ) ;
2531}
2632
33+ function extractText ( node : React . ReactNode ) : string {
34+ return React . Children . toArray ( node )
35+ . map ( ( child ) => {
36+ if ( typeof child === "string" || typeof child === "number" ) {
37+ return String ( child ) ;
38+ }
39+ if ( React . isValidElement < { children ?: React . ReactNode } > ( child ) && child . props ?. children ) {
40+ return extractText ( child . props . children ) ;
41+ }
42+ return "" ;
43+ } )
44+ . join ( "" ) ;
45+ }
46+
47+ function CodeBlock ( {
48+ inline,
49+ className,
50+ children,
51+ ...props
52+ } : DetailedHTMLProps < HTMLAttributes < HTMLElement > , HTMLElement > & { inline ?: boolean } ) {
53+ const [ copied , setCopied ] = useState ( false ) ;
54+ const timeoutRef = useRef < NodeJS . Timeout | null > ( null ) ;
55+ const isInline = inline || ! className || ! className . includes ( "language-" ) ;
56+ const rest = props ;
57+ const codeText = useMemo ( ( ) => extractText ( children ) . replace ( / \n $ / , "" ) , [ children ] ) ;
58+
59+ useEffect ( ( ) => {
60+ return ( ) => {
61+ if ( timeoutRef . current ) {
62+ clearTimeout ( timeoutRef . current ) ;
63+ }
64+ } ;
65+ } , [ ] ) ;
66+
67+ if ( isInline ) {
68+ return (
69+ < code className = { className } { ...rest } >
70+ { children }
71+ </ code >
72+ ) ;
73+ }
74+
75+ const handleCopy = async ( ) => {
76+ try {
77+ await copyToClipboard ( codeText ) ;
78+ setCopied ( true ) ;
79+ if ( timeoutRef . current ) {
80+ clearTimeout ( timeoutRef . current ) ;
81+ }
82+ timeoutRef . current = setTimeout ( ( ) => setCopied ( false ) , 1200 ) ;
83+ } catch ( error ) {
84+ console . error ( "Failed to copy code block" , error ) ;
85+ }
86+ } ;
87+
88+ return (
89+ < div className = "relative group" >
90+ < button
91+ type = "button"
92+ aria-label = { copied ? "Copied" : "Copy code" }
93+ onClick = { handleCopy }
94+ className = { cn (
95+ "absolute right-2 top-2 rounded-md border border-border/50 bg-background/80 px-2 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ,
96+ copied ? "text-green-600 scale-105 animate-pulse" : "hover:text-foreground hover:-translate-y-0.5" ,
97+ codeText ? "opacity-100" : "hidden"
98+ ) }
99+ >
100+ { copied ? < Check className = "size-4" /> : < Copy className = "size-4" /> }
101+ < span className = "sr-only" > { copied ? "Copied" : "Copy code" } </ span >
102+ </ button >
103+ < pre className = { className } >
104+ < code className = { className } { ...rest } > { children } </ code >
105+ </ pre >
106+ </ div >
107+ ) ;
108+ }
109+
27110export default function MarkdownRenderer ( { content } : { content : string } ) {
28111 return (
29112 < div
@@ -36,6 +119,7 @@ export default function MarkdownRenderer({ content }: { content: string }) {
36119 components = { {
37120 ul : ( props ) => < ul className = "list-disc" { ...props } /> ,
38121 ol : ( props ) => < ol className = "list-decimal" { ...props } /> ,
122+ code : CodeBlock ,
39123 } }
40124 >
41125 { NormalizeMathTags ( content ) }
0 commit comments