1
- import { useState , memo , useMemo , useCallback } from "react" ;
1
+ import { useState , memo , useMemo , useCallback , useEffect } from "react" ;
2
+ import type React from "react" ;
2
3
import type { JsonValue } from "@/utils/jsonUtils" ;
3
4
import clsx from "clsx" ;
4
5
import { Copy , CheckCheck } from "lucide-react" ;
@@ -101,6 +102,7 @@ const JsonNode = memo(
101
102
initialExpandDepth,
102
103
isError = false ,
103
104
} : JsonNodeProps ) => {
105
+ const { toast } = useToast ( ) ;
104
106
const [ isExpanded , setIsExpanded ] = useState ( depth < initialExpandDepth ) ;
105
107
const [ typeStyleMap ] = useState < Record < string , string > > ( {
106
108
number : "text-blue-600" ,
@@ -113,6 +115,52 @@ const JsonNode = memo(
113
115
} ) ;
114
116
const dataType = getDataType ( data ) ;
115
117
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
+
116
164
const renderCollapsible = ( isArray : boolean ) => {
117
165
const items = isArray
118
166
? ( data as JsonValue [ ] )
@@ -206,7 +254,7 @@ const JsonNode = memo(
206
254
207
255
if ( ! isTooLong ) {
208
256
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 " >
210
258
{ name && (
211
259
< span className = "mr-1 text-gray-600 dark:text-gray-400" >
212
260
{ name } :
@@ -220,12 +268,28 @@ const JsonNode = memo(
220
268
>
221
269
"{ value } "
222
270
</ 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 >
223
287
</ div >
224
288
) ;
225
289
}
226
290
227
291
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 " >
229
293
{ name && (
230
294
< span className = "mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400" >
231
295
{ name } :
@@ -241,6 +305,22 @@ const JsonNode = memo(
241
305
>
242
306
{ isExpanded ? `"${ value } "` : `"${ value . slice ( 0 , maxLength ) } ..."` }
243
307
</ 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 >
244
324
</ div >
245
325
) ;
246
326
} ;
@@ -253,7 +333,7 @@ const JsonNode = memo(
253
333
return renderString ( data as string ) ;
254
334
default :
255
335
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 " >
257
337
{ name && (
258
338
< span className = "mr-1 text-gray-600 dark:text-gray-400" >
259
339
{ name } :
@@ -262,6 +342,22 @@ const JsonNode = memo(
262
342
< span className = { typeStyleMap [ dataType ] || typeStyleMap . default } >
263
343
{ data === null ? "null" : String ( data ) }
264
344
</ 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 >
265
361
</ div >
266
362
) ;
267
363
}
0 commit comments