1
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" ;
@@ -114,6 +115,7 @@ const JsonNode = memo(
114
115
initialExpandDepth,
115
116
isError = false ,
116
117
} : JsonNodeProps ) => {
118
+ const { toast } = useToast ( ) ;
117
119
const [ isExpanded , setIsExpanded ] = useState ( depth < initialExpandDepth ) ;
118
120
const [ typeStyleMap ] = useState < Record < string , string > > ( {
119
121
number : "text-blue-600" ,
@@ -126,6 +128,52 @@ const JsonNode = memo(
126
128
} ) ;
127
129
const dataType = getDataType ( data ) ;
128
130
131
+ const [ copied , setCopied ] = useState ( false ) ;
132
+ useEffect ( ( ) => {
133
+ let timeoutId : NodeJS . Timeout ;
134
+ if ( copied ) {
135
+ timeoutId = setTimeout ( ( ) => setCopied ( false ) , 500 ) ;
136
+ }
137
+ return ( ) => {
138
+ if ( timeoutId ) clearTimeout ( timeoutId ) ;
139
+ } ;
140
+ } , [ copied ] ) ;
141
+
142
+ const handleCopyValue = useCallback (
143
+ ( value : JsonValue ) => {
144
+ try {
145
+ let text : string ;
146
+ const valueType = getDataType ( value ) ;
147
+ switch ( valueType ) {
148
+ case "string" :
149
+ text = value as unknown as string ;
150
+ break ;
151
+ case "number" :
152
+ case "boolean" :
153
+ text = String ( value ) ;
154
+ break ;
155
+ case "null" :
156
+ text = "null" ;
157
+ break ;
158
+ case "undefined" :
159
+ text = "undefined" ;
160
+ break ;
161
+ default :
162
+ text = JSON . stringify ( value ) ;
163
+ }
164
+ navigator . clipboard . writeText ( text ) ;
165
+ setCopied ( true ) ;
166
+ } catch ( error ) {
167
+ toast ( {
168
+ title : "Error" ,
169
+ description : `There was an error coping result into the clipboard: ${ error instanceof Error ? error . message : String ( error ) } ` ,
170
+ variant : "destructive" ,
171
+ } ) ;
172
+ }
173
+ } ,
174
+ [ toast ] ,
175
+ ) ;
176
+
129
177
const renderCollapsible = ( isArray : boolean ) => {
130
178
const items = isArray
131
179
? ( data as JsonValue [ ] )
@@ -219,7 +267,7 @@ const JsonNode = memo(
219
267
220
268
if ( ! isTooLong ) {
221
269
return (
222
- < div className = "flex mr-1 rounded hover:bg-gray-800/20" >
270
+ < div className = "flex mr-1 rounded hover:bg-gray-800/20 group items-start " >
223
271
{ name && (
224
272
< span className = "mr-1 text-gray-600 dark:text-gray-400" >
225
273
{ name } :
@@ -233,12 +281,28 @@ const JsonNode = memo(
233
281
>
234
282
"{ value } "
235
283
</ pre >
284
+ < Button
285
+ variant = "ghost"
286
+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
287
+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
288
+ e . stopPropagation ( ) ;
289
+ handleCopyValue ( value as unknown as JsonValue ) ;
290
+ } }
291
+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
292
+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
293
+ >
294
+ { copied ? (
295
+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
296
+ ) : (
297
+ < Copy className = "size-4 text-foreground" />
298
+ ) }
299
+ </ Button >
236
300
</ div >
237
301
) ;
238
302
}
239
303
240
304
return (
241
- < div className = "flex mr-1 rounded group hover:bg-gray-800/20" >
305
+ < div className = "flex mr-1 rounded group hover:bg-gray-800/20 items-start " >
242
306
{ name && (
243
307
< span className = "mr-1 text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-100 group-hover:text-gray-400" >
244
308
{ name } :
@@ -254,6 +318,22 @@ const JsonNode = memo(
254
318
>
255
319
{ isExpanded ? `"${ value } "` : `"${ value . slice ( 0 , maxLength ) } ..."` }
256
320
</ pre >
321
+ < Button
322
+ variant = "ghost"
323
+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
324
+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
325
+ e . stopPropagation ( ) ;
326
+ handleCopyValue ( value as unknown as JsonValue ) ;
327
+ } }
328
+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
329
+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
330
+ >
331
+ { copied ? (
332
+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
333
+ ) : (
334
+ < Copy className = "size-4 text-foreground" />
335
+ ) }
336
+ </ Button >
257
337
</ div >
258
338
) ;
259
339
} ;
@@ -266,7 +346,7 @@ const JsonNode = memo(
266
346
return renderString ( data as string ) ;
267
347
default :
268
348
return (
269
- < div className = "flex items-center mr-1 rounded hover:bg-gray-800/20" >
349
+ < div className = "flex items-center mr-1 rounded hover:bg-gray-800/20 group " >
270
350
{ name && (
271
351
< span className = "mr-1 text-gray-600 dark:text-gray-400" >
272
352
{ name } :
@@ -275,6 +355,22 @@ const JsonNode = memo(
275
355
< span className = { typeStyleMap [ dataType ] || typeStyleMap . default } >
276
356
{ data === null ? "null" : String ( data ) }
277
357
</ span >
358
+ < Button
359
+ variant = "ghost"
360
+ className = "ml-1 h-6 w-6 p-0 opacity-0 group-hover:opacity-100"
361
+ onClick = { ( e : React . MouseEvent < HTMLButtonElement > ) => {
362
+ e . stopPropagation ( ) ;
363
+ handleCopyValue ( data as JsonValue ) ;
364
+ } }
365
+ aria-label = { name ? `Copy value of ${ name } ` : "Copy value" }
366
+ title = { name ? `Copy value of ${ name } ` : "Copy value" }
367
+ >
368
+ { copied ? (
369
+ < CheckCheck className = "size-4 dark:text-green-700 text-green-600" />
370
+ ) : (
371
+ < Copy className = "size-4 text-foreground" />
372
+ ) }
373
+ </ Button >
278
374
</ div >
279
375
) ;
280
376
}
0 commit comments