11"use client" ;
22
3- import { useEffect , useState } from "react" ;
3+ import { useEffect , useRef , useState } from "react" ;
44import { marked } from "marked" ;
55import DOMPurify from "dompurify" ;
66
@@ -10,6 +10,29 @@ interface ArticleContentProps {
1010
1111const ArticleContent : React . FC < ArticleContentProps > = ( { content } ) => {
1212 const [ sanitizedContent , setSanitizedContent ] = useState < string > ( "" ) ;
13+ const containerRef = useRef < HTMLDivElement | null > ( null ) ;
14+
15+ async function copyTextToClipboard ( text : string ) {
16+ if ( typeof navigator !== "undefined" && navigator . clipboard ?. writeText ) {
17+ await navigator . clipboard . writeText ( text ) ;
18+ return ;
19+ }
20+
21+ const textarea = document . createElement ( "textarea" ) ;
22+ textarea . value = text ;
23+ textarea . setAttribute ( "readonly" , "" ) ;
24+ textarea . style . position = "fixed" ;
25+ textarea . style . top = "-9999px" ;
26+ textarea . style . left = "-9999px" ;
27+ document . body . appendChild ( textarea ) ;
28+ textarea . select ( ) ;
29+ const ok = document . execCommand ( "copy" ) ;
30+ document . body . removeChild ( textarea ) ;
31+
32+ if ( ! ok ) {
33+ throw new Error ( "Copy failed" ) ;
34+ }
35+ }
1336
1437 useEffect ( ( ) => {
1538 async function sanitizeContent ( ) {
@@ -27,9 +50,74 @@ const ArticleContent: React.FC<ArticleContentProps> = ({ content }) => {
2750 sanitizeContent ( ) ;
2851 } , [ content ] ) ;
2952
53+ useEffect ( ( ) => {
54+ const root = containerRef . current ;
55+ if ( ! root ) return ;
56+
57+ const preBlocks = Array . from ( root . querySelectorAll ( "pre" ) ) ;
58+
59+ const copySvg =
60+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>' ;
61+ const checkSvg =
62+ '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>' ;
63+
64+ for ( const pre of preBlocks ) {
65+ const existingWrapper = pre . closest ( 'div[data-copy-wrapper="true"]' ) ;
66+ if ( existingWrapper ) continue ;
67+
68+ const codeText = pre . querySelector ( "code" ) ?. textContent ?? pre . textContent ?? "" ;
69+ if ( ! codeText . trim ( ) ) continue ;
70+
71+ const wrapper = document . createElement ( "div" ) ;
72+ wrapper . dataset . copyWrapper = "true" ;
73+ wrapper . className = "relative" ;
74+
75+ const parent = pre . parentNode ;
76+ if ( ! parent ) continue ;
77+
78+ parent . insertBefore ( wrapper , pre ) ;
79+ wrapper . appendChild ( pre ) ;
80+
81+ pre . style . paddingTop = "52px" ;
82+ pre . style . paddingRight = "52px" ;
83+
84+ const button = document . createElement ( "button" ) ;
85+ button . type = "button" ;
86+ button . className =
87+ "absolute top-3 right-3 h-8 w-8 flex items-center justify-center bg-[#121212] text-foreground transition-all duration-200 ease-out transform hover:scale-105 active:scale-95 hover:bg-[#8a2be2] hover:border-[#8a2be2] hover:text-white" ;
88+ button . innerHTML = copySvg ;
89+ button . setAttribute ( "aria-label" , "Copy code to clipboard" ) ;
90+ button . setAttribute ( "title" , "Copy" ) ;
91+
92+ let resetTimer : number | undefined ;
93+
94+ const handleClick = async ( ) => {
95+ const textToCopy = pre . querySelector ( "code" ) ?. textContent ?? pre . textContent ?? "" ;
96+ if ( ! textToCopy . trim ( ) ) return ;
97+
98+ try {
99+ await copyTextToClipboard ( textToCopy ) ;
100+ button . innerHTML = checkSvg ;
101+ button . setAttribute ( "title" , "Copied" ) ;
102+ } catch {
103+ button . setAttribute ( "title" , "Copy failed" ) ;
104+ }
105+
106+ if ( resetTimer ) window . clearTimeout ( resetTimer ) ;
107+ resetTimer = window . setTimeout ( ( ) => {
108+ button . innerHTML = copySvg ;
109+ button . setAttribute ( "title" , "Copy" ) ;
110+ } , 1500 ) ;
111+ } ;
112+
113+ button . addEventListener ( "click" , handleClick ) ;
114+ wrapper . appendChild ( button ) ;
115+ }
116+ } , [ sanitizedContent ] ) ;
117+
30118 return (
31119 < article className = "flex flex-col md:flex-row gap-6 md:gap-16 max-w-full lg:max-w-[70%] w-full mx-auto mt-6 md:mt-24 mb-10 px-4" >
32- < div className = "markdown-body w-full" >
120+ < div className = "markdown-body w-full" ref = { containerRef } >
33121 < div dangerouslySetInnerHTML = { { __html : sanitizedContent } } />
34122 </ div >
35123 </ article >
0 commit comments