1- import { useState , memo , useMemo , useCallback } from "react" ;
1+ import { useState , memo , useMemo , useCallback , useEffect } from "react" ;
2+ import type React from "react" ;
23import type { JsonValue } from "@/utils/jsonUtils" ;
34import clsx from "clsx" ;
45import { Copy , CheckCheck } from "lucide-react" ;
@@ -101,6 +102,7 @@ const JsonNode = memo(
101102 initialExpandDepth,
102103 isError = false ,
103104 } : JsonNodeProps ) => {
105+ const { toast } = useToast ( ) ;
104106 const [ isExpanded , setIsExpanded ] = useState ( depth < initialExpandDepth ) ;
105107 const [ typeStyleMap ] = useState < Record < string , string > > ( {
106108 number : "text-blue-600" ,
@@ -113,6 +115,52 @@ const JsonNode = memo(
113115 } ) ;
114116 const dataType = getDataType ( data ) ;
115117
118+ const [ copied , setCopied ] = useState ( false ) ;
119+ useEffect ( ( ) => {
120+ let timeoutId : NodeJS . Timeout ;
121+ if ( copied ) {
122+ timeoutId = setTimeout ( ( ) => setCopied ( false ) , 500 ) ;
123+ }
124+ return ( ) => {
125+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
126+ } ;
127+ } , [ copied ] ) ;
128+
129+ const handleCopyValue = useCallback (
130+ ( value : JsonValue ) => {
131+ try {
132+ let text : string ;
133+ const valueType = getDataType ( value ) ;
134+ switch ( valueType ) {
135+ case "string" :
136+ text = value as unknown as string ;
137+ break ;
138+ case "number" :
139+ case "boolean" :
140+ text = String ( value ) ;
141+ break ;
142+ case "null" :
143+ text = "null" ;
144+ break ;
145+ case "undefined" :
146+ text = "undefined" ;
147+ break ;
148+ default :
149+ text = JSON . stringify ( value ) ;
150+ }
151+ navigator . clipboard . writeText ( text ) ;
152+ setCopied ( true ) ;
153+ } catch ( error ) {
154+ toast ( {
155+ title : "Error" ,
156+ description : `There was an error coping result into the clipboard: ${ error instanceof Error ? error . message : String ( error ) } ` ,
157+ variant : "destructive" ,
158+ } ) ;
159+ }
160+ } ,
161+ [ toast ] ,
162+ ) ;
163+
116164 const renderCollapsible = ( isArray : boolean ) => {
117165 const items = isArray
118166 ? ( data as JsonValue [ ] )
@@ -206,7 +254,7 @@ const JsonNode = memo(
206254
207255 if ( ! isTooLong ) {
208256 return (
209- < div className = "flex mr-1 rounded hover:bg-gray-800/20" >
257+ < div className = "flex mr-1 rounded hover:bg-gray-800/20 group items-start " >
210258 { name && (
211259 < span className = "mr-1 text-gray-600 dark:text-gray-400" >
212260 { name } :
@@ -220,12 +268,28 @@ const JsonNode = memo(
220268 >
221269 "{ value } "
222270 </ pre >
271+ < Button
272+ variant = "ghost"
273+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
274+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
275+ e . stopPropagation ( ) ;
276+ handleCopyValue ( value as unknown as JsonValue ) ;
277+ } }
278+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
279+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
280+ >
281+ { copied ? (
282+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
283+ ) : (
284+ < Copy className = "size-4 text-foreground" />
285+ ) }
286+ </ Button >
223287 </ div >
224288 ) ;
225289 }
226290
227291 return (
228- < div className = "flex mr-1 rounded group hover:bg-gray-800/20" >
292+ < div className = "flex mr-1 rounded group hover:bg-gray-800/20 items-start " >
229293 { name && (
230294 < span className = "mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400" >
231295 { name } :
@@ -241,6 +305,22 @@ const JsonNode = memo(
241305 >
242306 { isExpanded ? `"${ value } "` : `"${ value . slice ( 0 , maxLength ) } ..."` }
243307 </ pre >
308+ < Button
309+ variant = "ghost"
310+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
311+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
312+ e . stopPropagation ( ) ;
313+ handleCopyValue ( value as unknown as JsonValue ) ;
314+ } }
315+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
316+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
317+ >
318+ { copied ? (
319+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
320+ ) : (
321+ < Copy className = "size-4 text-foreground" />
322+ ) }
323+ </ Button >
244324 </ div >
245325 ) ;
246326 } ;
@@ -253,7 +333,7 @@ const JsonNode = memo(
253333 return renderString ( data as string ) ;
254334 default :
255335 return (
256- < div className = "flex items-center mr-1 rounded hover:bg-gray-800/20" >
336+ < div className = "flex items-center mr-1 rounded hover:bg-gray-800/20 group " >
257337 { name && (
258338 < span className = "mr-1 text-gray-600 dark:text-gray-400" >
259339 { name } :
@@ -262,6 +342,22 @@ const JsonNode = memo(
262342 < span className = { typeStyleMap [ dataType ] || typeStyleMap . default } >
263343 { data === null ? "null" : String ( data ) }
264344 </ span >
345+ < Button
346+ variant = "ghost"
347+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
348+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
349+ e . stopPropagation ( ) ;
350+ handleCopyValue ( data as JsonValue ) ;
351+ } }
352+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
353+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
354+ >
355+ { copied ? (
356+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
357+ ) : (
358+ < Copy className = "size-4 text-foreground" />
359+ ) }
360+ </ Button >
265361 </ div >
266362 ) ;
267363 }
0 commit comments