@@ -8,13 +8,13 @@ import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
8
8
import { getAIChatName } from '@/components/AIChat' ;
9
9
import { AIChatIcon } from '@/components/AIChat' ;
10
10
import { Button } from '@/components/primitives/Button' ;
11
- import { DropdownMenuItem } from '@/components/primitives/DropdownMenu' ;
11
+ import { DropdownMenuItem , useDropdownMenuClose } from '@/components/primitives/DropdownMenu' ;
12
12
import { tString , useLanguage } from '@/intl/client' ;
13
13
import type { TranslationLanguage } from '@/intl/translations' ;
14
14
import { Icon , type IconName , IconStyle } from '@gitbook/icons' ;
15
15
import assertNever from 'assert-never' ;
16
+ import QuickLRU from 'quick-lru' ;
16
17
import type React from 'react' ;
17
- import { useEffect , useRef } from 'react' ;
18
18
import { create } from 'zustand' ;
19
19
20
20
type AIActionType = 'button' | 'dropdown-menu-item' ;
@@ -53,19 +53,50 @@ export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolea
53
53
) ;
54
54
}
55
55
56
- // We need to store the copied state in a store to share the state between the
57
- // copy button and the dropdown menu item.
58
- const useCopiedStore = create < {
56
+ type CopiedStore = {
59
57
copied : boolean ;
60
- setCopied : ( copied : boolean ) => void ;
61
58
loading : boolean ;
62
- setLoading : ( loading : boolean ) => void ;
63
- } > ( ( set ) => ( {
64
- copied : false ,
65
- setCopied : ( copied : boolean ) => set ( { copied } ) ,
66
- loading : false ,
67
- setLoading : ( loading : boolean ) => set ( { loading } ) ,
68
- } ) ) ;
59
+ } ;
60
+
61
+ // We need to store everything in a store to share the state between every instance of the component.
62
+ const useCopiedStore = create <
63
+ CopiedStore & {
64
+ setLoading : ( loading : boolean ) => void ;
65
+ copy : ( data : string , opts ?: { onSuccess ?: ( ) => void } ) => void ;
66
+ }
67
+ > ( ( set ) => {
68
+ let timeoutRef : ReturnType < typeof setTimeout > | null = null ;
69
+
70
+ return {
71
+ copied : false ,
72
+ loading : false ,
73
+ setLoading : ( loading : boolean ) => set ( { loading } ) ,
74
+ copy : async ( data , opts ) => {
75
+ const { onSuccess } = opts || { } ;
76
+
77
+ if ( timeoutRef ) {
78
+ clearTimeout ( timeoutRef ) ;
79
+ }
80
+
81
+ await navigator . clipboard . writeText ( data ) ;
82
+
83
+ set ( { copied : true } ) ;
84
+
85
+ timeoutRef = setTimeout ( ( ) => {
86
+ set ( { copied : false } ) ;
87
+ onSuccess ?.( ) ;
88
+
89
+ // Reset the timeout ref to avoid multiple timeouts
90
+ timeoutRef = null ;
91
+ } , 1500 ) ;
92
+ } ,
93
+ } ;
94
+ } ) ;
95
+
96
+ /**
97
+ * Cache for the markdown versbion of the page.
98
+ */
99
+ const markdownCache = new QuickLRU < string , string > ( { maxSize : 10 } ) ;
69
100
70
101
/**
71
102
* Copies the markdown version of the page to the clipboard.
@@ -77,61 +108,38 @@ export function CopyMarkdown(props: {
77
108
} ) {
78
109
const { markdownPageUrl, type, isDefaultAction } = props ;
79
110
const language = useLanguage ( ) ;
80
- const { copied, setCopied, loading, setLoading } = useCopiedStore ( ) ;
81
- const timeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
82
111
83
- // Close the dropdown menu manually after the copy button is clicked
84
- const closeDropdownMenu = ( ) => {
85
- const dropdownMenu = document . querySelector ( 'div[data-radix-popper-content-wrapper]' ) ;
112
+ const closeDropdown = useDropdownMenuClose ( ) ;
86
113
87
- // Cancel if no dropdown menu is open
88
- if ( ! dropdownMenu ) return ;
89
-
90
- // Dispatch on `document` so that the event is captured by Radix's
91
- // dismissable-layer listener regardless of focus location.
92
- document . dispatchEvent ( new KeyboardEvent ( 'keydown' , { key : 'Escape' , bubbles : true } ) ) ;
93
- } ;
114
+ const { copied, loading, setLoading, copy } = useCopiedStore ( ) ;
94
115
95
116
// Fetch the markdown from the page
96
117
const fetchMarkdown = async ( ) => {
97
118
setLoading ( true ) ;
98
119
99
- return fetch ( markdownPageUrl )
100
- . then ( ( res ) => res . text ( ) )
101
- . finally ( ( ) => setLoading ( false ) ) ;
102
- } ;
120
+ const result = await fetch ( markdownPageUrl ) . then ( ( res ) => res . text ( ) ) ;
121
+ markdownCache . set ( markdownPageUrl , result ) ;
103
122
104
- // Reset the copied state when the component unmounts
105
- useEffect ( ( ) => {
106
- return ( ) => {
107
- if ( timeoutRef . current ) {
108
- clearTimeout ( timeoutRef . current ) ;
109
- }
110
- } ;
111
- } , [ ] ) ;
123
+ setLoading ( false ) ;
124
+
125
+ return result ;
126
+ } ;
112
127
113
128
const onClick = async ( e : React . MouseEvent ) => {
114
129
// Prevent default behavior for non-default actions to avoid closing the dropdown.
115
130
// This allows showing transient UI (e.g., a "copied" state) inside the menu item.
116
- // Default action buttons are excluded from this behavior.
117
131
if ( ! isDefaultAction ) {
118
132
e . preventDefault ( ) ;
119
133
}
120
134
121
- const markdown = await fetchMarkdown ( ) ;
122
-
123
- navigator . clipboard . writeText ( markdown ) ;
124
- setCopied ( true ) ;
125
-
126
- // Reset the copied state after 2 seconds
127
- timeoutRef . current = setTimeout ( ( ) => {
128
- // Close the dropdown menu if it's a dropdown menu item and not the default action
129
- if ( type === 'dropdown-menu-item' && ! isDefaultAction ) {
130
- closeDropdownMenu ( ) ;
131
- }
132
-
133
- setCopied ( false ) ;
134
- } , 2000 ) ;
135
+ copy ( markdownCache . get ( markdownPageUrl ) || ( await fetchMarkdown ( ) ) , {
136
+ onSuccess : ( ) => {
137
+ // We close the dropdown menu if the action is a dropdown menu item and not the default action.
138
+ if ( type === 'dropdown-menu-item' && ! isDefaultAction ) {
139
+ closeDropdown ( ) ;
140
+ }
141
+ } ,
142
+ } ) ;
135
143
} ;
136
144
137
145
return (
@@ -224,7 +232,7 @@ function AIActionWrapper(props: {
224
232
size = "xsmall"
225
233
variant = "secondary"
226
234
label = { shortLabel || label }
227
- className = "hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm"
235
+ className = "hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm"
228
236
onClick = { onClick }
229
237
href = { href }
230
238
target = { href ? '_blank' : undefined }
@@ -239,21 +247,24 @@ function AIActionWrapper(props: {
239
247
href = { href }
240
248
target = "_blank"
241
249
onClick = { onClick }
242
- disabled = { disabled }
250
+ disabled = { disabled || loading }
243
251
>
244
- { icon ? (
245
- < div className = "flex size-5 items-center justify-center text-tint" >
246
- { typeof icon === 'string' ? (
252
+ < div className = "flex size-5 items-center justify-center text-tint" >
253
+ { loading ? (
254
+ < Icon icon = "spinner-third" className = "size-4 animate-spin" />
255
+ ) : icon ? (
256
+ typeof icon === 'string' ? (
247
257
< Icon
248
258
icon = { icon as IconName }
249
259
iconStyle = { IconStyle . Regular }
250
260
className = "size-4 fill-transparent stroke-current"
251
261
/>
252
262
) : (
253
263
icon
254
- ) }
255
- </ div >
256
- ) : null }
264
+ )
265
+ ) : null }
266
+ </ div >
267
+
257
268
< div className = "flex flex-1 flex-col gap-0.5" >
258
269
< span className = "flex items-center gap-2 text-tint-strong" >
259
270
< span className = "truncate font-medium text-sm" > { label } </ span >
0 commit comments