@@ -8,13 +8,13 @@ import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
88import { getAIChatName } from '@/components/AIChat' ;
99import { AIChatIcon } from '@/components/AIChat' ;
1010import { Button } from '@/components/primitives/Button' ;
11- import { DropdownMenuItem } from '@/components/primitives/DropdownMenu' ;
11+ import { DropdownMenuItem , useDropdownMenuClose } from '@/components/primitives/DropdownMenu' ;
1212import { tString , useLanguage } from '@/intl/client' ;
1313import type { TranslationLanguage } from '@/intl/translations' ;
1414import { Icon , type IconName , IconStyle } from '@gitbook/icons' ;
1515import assertNever from 'assert-never' ;
16+ import QuickLRU from 'quick-lru' ;
1617import type React from 'react' ;
17- import { useEffect , useRef } from 'react' ;
1818import { create } from 'zustand' ;
1919
2020type AIActionType = 'button' | 'dropdown-menu-item' ;
@@ -53,19 +53,50 @@ export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolea
5353 ) ;
5454}
5555
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 = {
5957 copied : boolean ;
60- setCopied : ( copied : boolean ) => void ;
6158 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 } ) ;
69100
70101/**
71102 * Copies the markdown version of the page to the clipboard.
@@ -77,61 +108,38 @@ export function CopyMarkdown(props: {
77108} ) {
78109 const { markdownPageUrl, type, isDefaultAction } = props ;
79110 const language = useLanguage ( ) ;
80- const { copied, setCopied, loading, setLoading } = useCopiedStore ( ) ;
81- const timeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
82111
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 ( ) ;
86113
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 ( ) ;
94115
95116 // Fetch the markdown from the page
96117 const fetchMarkdown = async ( ) => {
97118 setLoading ( true ) ;
98119
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 ) ;
103122
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+ } ;
112127
113128 const onClick = async ( e : React . MouseEvent ) => {
114129 // Prevent default behavior for non-default actions to avoid closing the dropdown.
115130 // This allows showing transient UI (e.g., a "copied" state) inside the menu item.
116- // Default action buttons are excluded from this behavior.
117131 if ( ! isDefaultAction ) {
118132 e . preventDefault ( ) ;
119133 }
120134
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+ } ) ;
135143 } ;
136144
137145 return (
@@ -224,7 +232,7 @@ function AIActionWrapper(props: {
224232 size = "xsmall"
225233 variant = "secondary"
226234 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"
228236 onClick = { onClick }
229237 href = { href }
230238 target = { href ? '_blank' : undefined }
@@ -239,21 +247,24 @@ function AIActionWrapper(props: {
239247 href = { href }
240248 target = "_blank"
241249 onClick = { onClick }
242- disabled = { disabled }
250+ disabled = { disabled || loading }
243251 >
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' ? (
247257 < Icon
248258 icon = { icon as IconName }
249259 iconStyle = { IconStyle . Regular }
250260 className = "size-4 fill-transparent stroke-current"
251261 />
252262 ) : (
253263 icon
254- ) }
255- </ div >
256- ) : null }
264+ )
265+ ) : null }
266+ </ div >
267+
257268 < div className = "flex flex-1 flex-col gap-0.5" >
258269 < span className = "flex items-center gap-2 text-tint-strong" >
259270 < span className = "truncate font-medium text-sm" > { label } </ span >
0 commit comments