diff --git a/.changeset/great-baboons-smell.md b/.changeset/great-baboons-smell.md new file mode 100644 index 0000000000..1bf21ae081 --- /dev/null +++ b/.changeset/great-baboons-smell.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Refactor icon loading state in AIAction components diff --git a/bun.lock b/bun.lock index 3e318449ff..fdfb6880a0 100644 --- a/bun.lock +++ b/bun.lock @@ -97,6 +97,7 @@ "object-identity": "^0.1.2", "openapi-types": "^12.1.3", "p-map": "^7.0.3", + "quick-lru": "^7.0.1", "react-hotkeys-hook": "^4.4.1", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", @@ -2502,7 +2503,7 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], + "quick-lru": ["quick-lru@7.0.1", "", {}, "sha512-kLjThirJMkWKutUKbZ8ViqFc09tDQhlbQo2MNuVeLWbRauqYP96Sm6nzlQ24F0HFjUNZ4i9+AgldJ9H6DZXi7g=="], "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=="], @@ -4046,6 +4047,8 @@ "cacheable-request/lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], + "camelcase-keys/quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], + "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=="], "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=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 7a0ca7e2bf..bb59397dcc 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -67,7 +67,8 @@ "url-join": "^5.0.0", "usehooks-ts": "^3.1.0", "warn-once": "^0.1.1", - "zustand": "^5.0.3" + "zustand": "^5.0.3", + "quick-lru": "^7.0.1" }, "devDependencies": { "@argos-ci/playwright": "^5.0.5", diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 0916bca818..ab0743ad75 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -8,13 +8,13 @@ import { MarkdownIcon } from '@/components/AIActions/assets/MarkdownIcon'; import { getAIChatName } from '@/components/AIChat'; import { AIChatIcon } from '@/components/AIChat'; import { Button } from '@/components/primitives/Button'; -import { DropdownMenuItem } from '@/components/primitives/DropdownMenu'; +import { DropdownMenuItem, useDropdownMenuClose } from '@/components/primitives/DropdownMenu'; import { tString, useLanguage } from '@/intl/client'; import type { TranslationLanguage } from '@/intl/translations'; import { Icon, type IconName, IconStyle } from '@gitbook/icons'; import assertNever from 'assert-never'; +import QuickLRU from 'quick-lru'; import type React from 'react'; -import { useEffect, useRef } from 'react'; import { create } from 'zustand'; type AIActionType = 'button' | 'dropdown-menu-item'; @@ -53,19 +53,50 @@ export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolea ); } -// We need to store the copied state in a store to share the state between the -// copy button and the dropdown menu item. -const useCopiedStore = create<{ +type CopiedStore = { copied: boolean; - setCopied: (copied: boolean) => void; loading: boolean; - setLoading: (loading: boolean) => void; -}>((set) => ({ - copied: false, - setCopied: (copied: boolean) => set({ copied }), - loading: false, - setLoading: (loading: boolean) => set({ loading }), -})); +}; + +// We need to store everything in a store to share the state between every instance of the component. +const useCopiedStore = create< + CopiedStore & { + setLoading: (loading: boolean) => void; + copy: (data: string, opts?: { onSuccess?: () => void }) => void; + } +>((set) => { + let timeoutRef: ReturnType | null = null; + + return { + copied: false, + loading: false, + setLoading: (loading: boolean) => set({ loading }), + copy: async (data, opts) => { + const { onSuccess } = opts || {}; + + if (timeoutRef) { + clearTimeout(timeoutRef); + } + + await navigator.clipboard.writeText(data); + + set({ copied: true }); + + timeoutRef = setTimeout(() => { + set({ copied: false }); + onSuccess?.(); + + // Reset the timeout ref to avoid multiple timeouts + timeoutRef = null; + }, 1500); + }, + }; +}); + +/** + * Cache for the markdown versbion of the page. + */ +const markdownCache = new QuickLRU({ maxSize: 10 }); /** * Copies the markdown version of the page to the clipboard. @@ -77,61 +108,38 @@ export function CopyMarkdown(props: { }) { const { markdownPageUrl, type, isDefaultAction } = props; const language = useLanguage(); - const { copied, setCopied, loading, setLoading } = useCopiedStore(); - const timeoutRef = useRef | null>(null); - // Close the dropdown menu manually after the copy button is clicked - const closeDropdownMenu = () => { - const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); + const closeDropdown = useDropdownMenuClose(); - // Cancel if no dropdown menu is open - if (!dropdownMenu) return; - - // Dispatch on `document` so that the event is captured by Radix's - // dismissable-layer listener regardless of focus location. - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - }; + const { copied, loading, setLoading, copy } = useCopiedStore(); // Fetch the markdown from the page const fetchMarkdown = async () => { setLoading(true); - return fetch(markdownPageUrl) - .then((res) => res.text()) - .finally(() => setLoading(false)); - }; + const result = await fetch(markdownPageUrl).then((res) => res.text()); + markdownCache.set(markdownPageUrl, result); - // Reset the copied state when the component unmounts - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); + setLoading(false); + + return result; + }; const onClick = async (e: React.MouseEvent) => { // Prevent default behavior for non-default actions to avoid closing the dropdown. // This allows showing transient UI (e.g., a "copied" state) inside the menu item. - // Default action buttons are excluded from this behavior. if (!isDefaultAction) { e.preventDefault(); } - const markdown = await fetchMarkdown(); - - navigator.clipboard.writeText(markdown); - setCopied(true); - - // Reset the copied state after 2 seconds - timeoutRef.current = setTimeout(() => { - // Close the dropdown menu if it's a dropdown menu item and not the default action - if (type === 'dropdown-menu-item' && !isDefaultAction) { - closeDropdownMenu(); - } - - setCopied(false); - }, 2000); + copy(markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), { + onSuccess: () => { + // We close the dropdown menu if the action is a dropdown menu item and not the default action. + if (type === 'dropdown-menu-item' && !isDefaultAction) { + closeDropdown(); + } + }, + }); }; return ( @@ -224,7 +232,7 @@ function AIActionWrapper(props: { size="xsmall" variant="secondary" label={shortLabel || label} - className="hover:!scale-100 !shadow-none !rounded-r-none border-r-0 bg-tint-base text-sm" + className="hover:!scale-100 !shadow-none !rounded-r-none hover:!translate-y-0 border-r-0 bg-tint-base text-sm" onClick={onClick} href={href} target={href ? '_blank' : undefined} @@ -239,11 +247,13 @@ function AIActionWrapper(props: { href={href} target="_blank" onClick={onClick} - disabled={disabled} + disabled={disabled || loading} > - {icon ? ( -
- {typeof icon === 'string' ? ( +
+ {loading ? ( + + ) : icon ? ( + typeof icon === 'string' ? ( ) : ( icon - )} -
- ) : null} + ) + ) : null} +
+
{label} diff --git a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx index 617f6bb177..d10d2abfef 100644 --- a/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx +++ b/packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx @@ -45,7 +45,7 @@ export function AIActionsDropdown(props: AIActionsDropdownProps) { iconOnly size="xsmall" variant="secondary" - className="hover:!scale-100 !shadow-none !rounded-l-none bg-tint-base text-sm" + className="hover:!scale-100 hover:!translate-y-0 !shadow-none !rounded-l-none bg-tint-base text-sm" /> } > diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 20bb7d29b1..ef3658474d 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -2,12 +2,13 @@ import { Icon } from '@gitbook/icons'; import type { DetailedHTMLProps, HTMLAttributes } from 'react'; -import { useState } from 'react'; +import { createContext, useCallback, useContext, useState } from 'react'; import { type ClassValue, tcls } from '@/lib/tailwind'; import * as RadixDropdownMenu from '@radix-ui/react-dropdown-menu'; +import { assert } from 'ts-essentials'; import { Link, type LinkInsightsProps } from '.'; export type DropdownButtonProps = Omit< @@ -15,6 +16,14 @@ export type DropdownButtonProps = Omit< 'ref' >; +const DropdownMenuContext = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; +}>({ + open: false, + setOpen: () => {}, +}); + /** * Button with a dropdown. */ @@ -47,46 +56,46 @@ export function DropdownMenu(props: { align = 'start', } = props; const [hovered, setHovered] = useState(false); - const [clicked, setClicked] = useState(false); + const [open, setOpen] = useState(false); - return ( - - setHovered(true)} - onMouseLeave={() => setHovered(false)} - onClick={() => (openOnHover ? setClicked(!clicked) : null)} - className="group/dropdown" - > - {button} - + const isOpen = openOnHover ? open || hovered : open; - - + + setHovered(true)} onMouseLeave={() => setHovered(false)} - align={align} - side={side} - className="z-40 animate-scaleIn border-tint pt-2" + onClick={() => (openOnHover ? setOpen(!open) : null)} + className="group/dropdown" > -
+ + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + align={align} + side={side} + className="z-40 animate-scaleIn border-tint pt-2" > - {children} -
-
-
-
+
+ {children} +
+ + + + ); } @@ -193,3 +202,12 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React ); } + +/** + * Hook to close the dropdown menu. + */ +export function useDropdownMenuClose() { + const context = useContext(DropdownMenuContext); + assert(context, 'DropdownMenuContext not found'); + return useCallback(() => context.setOpen(false), [context]); +}