Skip to content

Commit 9ee9082

Browse files
authored
Improvement for CopyMarkdown action (#3465)
1 parent 334cfdd commit 9ee9082

File tree

6 files changed

+136
-98
lines changed

6 files changed

+136
-98
lines changed

.changeset/great-baboons-smell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'gitbook': patch
3+
---
4+
5+
Refactor icon loading state in AIAction components

bun.lock

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"object-identity": "^0.1.2",
9898
"openapi-types": "^12.1.3",
9999
"p-map": "^7.0.3",
100+
"quick-lru": "^7.0.1",
100101
"react-hotkeys-hook": "^4.4.1",
101102
"rehype-sanitize": "^6.0.0",
102103
"rehype-stringify": "^10.0.1",
@@ -2502,7 +2503,7 @@
25022503

25032504
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
25042505

2505-
"quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
2506+
"quick-lru": ["quick-lru@7.0.1", "", {}, "sha512-kLjThirJMkWKutUKbZ8ViqFc09tDQhlbQo2MNuVeLWbRauqYP96Sm6nzlQ24F0HFjUNZ4i9+AgldJ9H6DZXi7g=="],
25062507

25072508
"radix-vue": ["radix-vue@1.9.7", "", { "dependencies": { "@floating-ui/dom": "^1.6.7", "@floating-ui/vue": "^1.1.0", "@internationalized/date": "^3.5.4", "@internationalized/number": "^3.5.3", "@tanstack/vue-virtual": "^3.8.1", "@vueuse/core": "^10.11.0", "@vueuse/shared": "^10.11.0", "aria-hidden": "^1.2.4", "defu": "^6.1.4", "fast-deep-equal": "^3.1.3", "nanoid": "^5.0.7" }, "peerDependencies": { "vue": ">= 3.2.0" } }, "sha512-1xleWzWNFPfAMmb81gu/4/MV8dXMvc7j2EIjutBpBcKwxdJfeIcQg4k9De18L2rL1/GZg5wA9KykeKTM4MjWow=="],
25082509

@@ -4046,6 +4047,8 @@
40464047

40474048
"cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
40484049

4050+
"camelcase-keys/quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],
4051+
40494052
"codemirror/@codemirror/autocomplete": ["@codemirror/autocomplete@6.18.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA=="],
40504053

40514054
"codemirror/@codemirror/commands": ["@codemirror/commands@6.7.0", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="],

packages/gitbook/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@
6767
"url-join": "^5.0.0",
6868
"usehooks-ts": "^3.1.0",
6969
"warn-once": "^0.1.1",
70-
"zustand": "^5.0.3"
70+
"zustand": "^5.0.3",
71+
"quick-lru": "^7.0.1"
7172
},
7273
"devDependencies": {
7374
"@argos-ci/playwright": "^5.0.5",

packages/gitbook/src/components/AIActions/AIActions.tsx

Lines changed: 71 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon';
88
import { getAIChatName } from '@/components/AIChat';
99
import { AIChatIcon } from '@/components/AIChat';
1010
import { Button } from '@/components/primitives/Button';
11-
import { DropdownMenuItem } from '@/components/primitives/DropdownMenu';
11+
import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu';
1212
import { tString, useLanguage } from '@/intl/client';
1313
import type { TranslationLanguage } from '@/intl/translations';
1414
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
1515
import assertNever from 'assert-never';
16+
import QuickLRU from 'quick-lru';
1617
import type React from 'react';
17-
import { useEffect, useRef } from 'react';
1818
import { create } from 'zustand';
1919

2020
type 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>

packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) {
4545
iconOnly
4646
size="xsmall"
4747
variant="secondary"
48-
className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm"
48+
className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm"
4949
/>
5050
}
5151
>

packages/gitbook/src/components/primitives/DropdownMenu.tsx

Lines changed: 53 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,28 @@
22

33
import { Icon } from '@gitbook/icons';
44
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
5-
import { useState } from 'react';
5+
import { createContext, useCallback, useContext, useState } from 'react';
66

77
import { type ClassValue, tcls } from '@/lib/tailwind';
88

99
import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu';
1010

11+
import { assert } from 'ts-essentials';
1112
import { Link, type LinkInsightsProps } from '.';
1213

1314
export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = Omit<
1415
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
1516
'ref'
1617
>;
1718

19+
const DropdownMenuContext = createContext<{
20+
open: boolean;
21+
setOpen: (open: boolean) => void;
22+
}>({
23+
open: false,
24+
setOpen: () => {},
25+
});
26+
1827
/**
1928
* Button with a dropdown.
2029
*/
@@ -47,46 +56,46 @@ export function DropdownMenu(props: {
4756
align = 'start',
4857
} = props;
4958
const [hovered, setHovered] = useState(false);
50-
const [clicked, setClicked] = useState(false);
59+
const [open, setOpen] = useState(false);
5160

52-
return (
53-
<RadixDropdownMenu.Root
54-
modal={false}
55-
open={openOnHover ? clicked || hovered : clicked}
56-
onOpenChange={setClicked}
57-
>
58-
<RadixDropdownMenu.Trigger
59-
asChild
60-
onMouseEnter={() => setHovered(true)}
61-
onMouseLeave={() => setHovered(false)}
62-
onClick={() => (openOnHover ? setClicked(!clicked) : null)}
63-
className="group/dropdown"
64-
>
65-
{button}
66-
</RadixDropdownMenu.Trigger>
61+
const isOpen = openOnHover ? open || hovered : open;
6762

68-
<RadixDropdownMenu.Portal>
69-
<RadixDropdownMenu.Content
70-
data-testid="dropdown-menu"
71-
hideWhenDetached
72-
collisionPadding={8}
63+
return (
64+
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
65+
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
66+
<RadixDropdownMenu.Trigger
67+
asChild
7368
onMouseEnter={() => setHovered(true)}
7469
onMouseLeave={() => setHovered(false)}
75-
align={align}
76-
side={side}
77-
className="z-40 animate-scaleIn border-tint pt-2"
70+
onClick={() => (openOnHover ? setOpen(!open) : null)}
71+
className="group/dropdown"
7872
>
79-
<div
80-
className={tcls(
81-
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
82-
className
83-
)}
73+
{button}
74+
</RadixDropdownMenu.Trigger>
75+
76+
<RadixDropdownMenu.Portal>
77+
<RadixDropdownMenu.Content
78+
data-testid="dropdown-menu"
79+
hideWhenDetached
80+
collisionPadding={8}
81+
onMouseEnter={() => setHovered(true)}
82+
onMouseLeave={() => setHovered(false)}
83+
align={align}
84+
side={side}
85+
className="z-40 animate-scaleIn border-tint pt-2"
8486
>
85-
{children}
86-
</div>
87-
</RadixDropdownMenu.Content>
88-
</RadixDropdownMenu.Portal>
89-
</RadixDropdownMenu.Root>
87+
<div
88+
className={tcls(
89+
'flex max-h-80 min-w-40 max-w-[40vw] flex-col gap-1 overflow-auto circular-corners:rounded-xl rounded-md straight-corners:rounded-none border border-tint bg-tint-base p-2 shadow-lg sm:min-w-52 sm:max-w-80',
90+
className
91+
)}
92+
>
93+
{children}
94+
</div>
95+
</RadixDropdownMenu.Content>
96+
</RadixDropdownMenu.Portal>
97+
</RadixDropdownMenu.Root>
98+
</DropdownMenuContext.Provider>
9099
);
91100
}
92101

@@ -193,3 +202,12 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
193202
</RadixDropdownMenu.Sub>
194203
);
195204
}
205+
206+
/**
207+
* Hook to close the dropdown menu.
208+
*/
209+
export function useDropdownMenuClose() {
210+
const context = useContext(DropdownMenuContext);
211+
assert(context, 'DropdownMenuContext not found');
212+
return useCallback(() => context.setOpen(false), [context]);
213+
}

0 commit comments

Comments
 (0)