From 896d687589fa220eba04cecdbafe1176b77cd533 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Fri, 11 Jul 2025 12:47:45 +0200 Subject: [PATCH 1/9] Refactor icon loading state in AIAction components Introduced IconWithLoading component to handle icon rendering with loading state for both Button and link variants in AIActionWrapper. Also reset copied and loading states in CopyMarkdown cleanup. This improves code reuse and UI consistency for loading indicators. --- .changeset/great-baboons-smell.md | 5 + .../src/components/AIActions/AIActions.tsx | 133 ++++++++++-------- 2 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 .changeset/great-baboons-smell.md 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/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 0916bca818..79bb64b64d 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -13,8 +13,9 @@ 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 { usePathname } from 'next/navigation'; import type React from 'react'; -import { useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { create } from 'zustand'; type AIActionType = 'button' | 'dropdown-menu-item'; @@ -53,19 +54,47 @@ 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 = { + markdown: string | null; 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 }), -})); + pathname: string; +}; + +// We need to store everything in a store to share the state between every instance of the component. +const useCopiedStore = create< + CopiedStore & { + setState: (partial: Partial) => void; + copyWithTimeout: (markdown: string) => void; + } +>((set) => { + let timeoutRef: ReturnType | null = null; + + return { + markdown: null, + copied: false, + loading: false, + pathname: '', + setState: (partial: Partial) => set((state) => ({ ...state, ...partial })), + copyWithTimeout: async (markdown: string) => { + // Clear any existing timeout + if (timeoutRef) { + clearTimeout(timeoutRef); + } + + await navigator.clipboard.writeText(markdown); + + // Set copied to true + set({ copied: true, markdown }); + + // Set timeout to reset copied state + timeoutRef = setTimeout(() => { + set({ copied: false }); + timeoutRef = null; + }, 1500); + }, + }; +}); /** * Copies the markdown version of the page to the clipboard. @@ -77,61 +106,50 @@ 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 basePathname = usePathname(); + const { copied, loading, markdown, pathname, setState, copyWithTimeout } = useCopiedStore(); - // 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 })); - }; + // Clear cached markdown when navigating to a new page + useEffect(() => { + if (basePathname && pathname !== basePathname) { + setState({ markdown: null, pathname: basePathname }); + } + }, [basePathname, pathname, setState]); // Fetch the markdown from the page const fetchMarkdown = async () => { - setLoading(true); + setState({ loading: true }); return fetch(markdownPageUrl) .then((res) => res.text()) - .finally(() => setLoading(false)); + .finally(() => setState({ loading: false })); }; - // Reset the copied state when the component unmounts - useEffect(() => { - return () => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - 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(); + // Copy the markdown to the clipboard + copyWithTimeout(markdown || (await fetchMarkdown())); - navigator.clipboard.writeText(markdown); - setCopied(true); + // Close dropdown after a short delay to allow the copied state to show + if (type === 'dropdown-menu-item' && !isDefaultAction) { + setTimeout(() => { + const dropdownMenu = document.querySelector( + 'div[data-radix-popper-content-wrapper]' + ); + if (!dropdownMenu) return; - // 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); + // 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 }) + ); + }, 1500); + } }; return ( @@ -239,11 +257,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} From 27475f2c56b01d50c87fa799a8d5662315305c99 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 09:32:55 +0200 Subject: [PATCH 2/9] fix --- .../src/components/AIActions/AIActions.tsx | 49 ++++++++++--------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 79bb64b64d..bd78ae81c9 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -65,7 +65,7 @@ type CopiedStore = { const useCopiedStore = create< CopiedStore & { setState: (partial: Partial) => void; - copyWithTimeout: (markdown: string) => void; + copyWithTimeout: (props: { markdown: string; shouldCloseDropdown: boolean }) => void; } >((set) => { let timeoutRef: ReturnType | null = null; @@ -76,26 +76,41 @@ const useCopiedStore = create< loading: false, pathname: '', setState: (partial: Partial) => set((state) => ({ ...state, ...partial })), - copyWithTimeout: async (markdown: string) => { - // Clear any existing timeout + copyWithTimeout: async (props) => { + const { markdown, shouldCloseDropdown } = props; + if (timeoutRef) { clearTimeout(timeoutRef); } await navigator.clipboard.writeText(markdown); - // Set copied to true - set({ copied: true, markdown }); + set({ copied: true, markdown, loading: false }); - // Set timeout to reset copied state timeoutRef = setTimeout(() => { set({ copied: false }); timeoutRef = null; + + if (shouldCloseDropdown) { + closeDropdown(); + } }, 1500); }, }; }); +/** + * Function to manually close the dropdown + */ +function closeDropdown() { + const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); + 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 })); +} + /** * Copies the markdown version of the page to the clipboard. */ @@ -132,24 +147,10 @@ export function CopyMarkdown(props: { e.preventDefault(); } - // Copy the markdown to the clipboard - copyWithTimeout(markdown || (await fetchMarkdown())); - - // Close dropdown after a short delay to allow the copied state to show - if (type === 'dropdown-menu-item' && !isDefaultAction) { - setTimeout(() => { - const dropdownMenu = document.querySelector( - 'div[data-radix-popper-content-wrapper]' - ); - 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 }) - ); - }, 1500); - } + copyWithTimeout({ + markdown: markdown || (await fetchMarkdown()), + shouldCloseDropdown: type === 'dropdown-menu-item' && !isDefaultAction, + }); }; return ( From 930c184460a62721bff3cfaa33e45c1be4476e58 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 10:15:28 +0200 Subject: [PATCH 3/9] Switch to a Map cache for markdown --- bun.lock | 5 ++- packages/gitbook/package.json | 3 +- .../src/components/AIActions/AIActions.tsx | 34 ++++++++----------- 3 files changed, 21 insertions(+), 21 deletions(-) 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 bd78ae81c9..54d96ae1a8 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -13,9 +13,8 @@ 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 { usePathname } from 'next/navigation'; +import QuickLRU from 'quick-lru'; import type React from 'react'; -import { useEffect } from 'react'; import { create } from 'zustand'; type AIActionType = 'button' | 'dropdown-menu-item'; @@ -55,10 +54,8 @@ export function OpenDocsAssistant(props: { type: AIActionType; trademark: boolea } type CopiedStore = { - markdown: string | null; copied: boolean; loading: boolean; - pathname: string; }; // We need to store everything in a store to share the state between every instance of the component. @@ -71,10 +68,8 @@ const useCopiedStore = create< let timeoutRef: ReturnType | null = null; return { - markdown: null, copied: false, loading: false, - pathname: '', setState: (partial: Partial) => set((state) => ({ ...state, ...partial })), copyWithTimeout: async (props) => { const { markdown, shouldCloseDropdown } = props; @@ -85,7 +80,7 @@ const useCopiedStore = create< await navigator.clipboard.writeText(markdown); - set({ copied: true, markdown, loading: false }); + set({ copied: true, loading: false }); timeoutRef = setTimeout(() => { set({ copied: false }); @@ -99,6 +94,11 @@ const useCopiedStore = create< }; }); +/** + * Cache for the markdown versbion of the page. + */ +const markdownCache = new QuickLRU({ maxSize: 10 }); + /** * Function to manually close the dropdown */ @@ -121,23 +121,19 @@ export function CopyMarkdown(props: { }) { const { markdownPageUrl, type, isDefaultAction } = props; const language = useLanguage(); - const basePathname = usePathname(); - const { copied, loading, markdown, pathname, setState, copyWithTimeout } = useCopiedStore(); - // Clear cached markdown when navigating to a new page - useEffect(() => { - if (basePathname && pathname !== basePathname) { - setState({ markdown: null, pathname: basePathname }); - } - }, [basePathname, pathname, setState]); + const { copied, loading, setState, copyWithTimeout } = useCopiedStore(); // Fetch the markdown from the page const fetchMarkdown = async () => { setState({ loading: true }); - return fetch(markdownPageUrl) - .then((res) => res.text()) - .finally(() => setState({ loading: false })); + const result = await fetch(markdownPageUrl).then((res) => res.text()); + markdownCache.set(markdownPageUrl, result); + + setState({ loading: false }); + + return result; }; const onClick = async (e: React.MouseEvent) => { @@ -148,7 +144,7 @@ export function CopyMarkdown(props: { } copyWithTimeout({ - markdown: markdown || (await fetchMarkdown()), + markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), shouldCloseDropdown: type === 'dropdown-menu-item' && !isDefaultAction, }); }; From 39ac2547fd5e4e0c24d8075e98c46b73ef74b722 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 10:54:15 +0200 Subject: [PATCH 4/9] Better dropdown menu close --- .../src/components/AIActions/AIActions.tsx | 49 +++++------ .../components/primitives/DropdownMenu.tsx | 88 +++++++++++-------- 2 files changed, 77 insertions(+), 60 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 54d96ae1a8..714f526e11 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -8,7 +8,7 @@ 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'; @@ -62,7 +62,7 @@ type CopiedStore = { const useCopiedStore = create< CopiedStore & { setState: (partial: Partial) => void; - copyWithTimeout: (props: { markdown: string; shouldCloseDropdown: boolean }) => void; + copyWithTimeout: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void; } >((set) => { let timeoutRef: ReturnType | null = null; @@ -71,8 +71,9 @@ const useCopiedStore = create< copied: false, loading: false, setState: (partial: Partial) => set((state) => ({ ...state, ...partial })), - copyWithTimeout: async (props) => { - const { markdown, shouldCloseDropdown } = props; + copyWithTimeout: async (props, opts) => { + const { markdown } = props; + const { onSuccess } = opts || {}; if (timeoutRef) { clearTimeout(timeoutRef); @@ -80,15 +81,14 @@ const useCopiedStore = create< await navigator.clipboard.writeText(markdown); - set({ copied: true, loading: false }); + set({ copied: true }); timeoutRef = setTimeout(() => { set({ copied: false }); - timeoutRef = null; + onSuccess?.(); - if (shouldCloseDropdown) { - closeDropdown(); - } + // Reset the timeout ref to avoid multiple timeouts + timeoutRef = null; }, 1500); }, }; @@ -99,18 +99,6 @@ const useCopiedStore = create< */ const markdownCache = new QuickLRU({ maxSize: 10 }); -/** - * Function to manually close the dropdown - */ -function closeDropdown() { - const dropdownMenu = document.querySelector('div[data-radix-popper-content-wrapper]'); - 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 })); -} - /** * Copies the markdown version of the page to the clipboard. */ @@ -122,6 +110,8 @@ export function CopyMarkdown(props: { const { markdownPageUrl, type, isDefaultAction } = props; const language = useLanguage(); + const closeDropdown = useDropdownMenuClose(); + const { copied, loading, setState, copyWithTimeout } = useCopiedStore(); // Fetch the markdown from the page @@ -143,10 +133,19 @@ export function CopyMarkdown(props: { e.preventDefault(); } - copyWithTimeout({ - markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), - shouldCloseDropdown: type === 'dropdown-menu-item' && !isDefaultAction, - }); + copyWithTimeout( + { + markdown: 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 ( diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index 20bb7d29b1..b48a44ef6a 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, 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 () => context.setOpen(false); +} From d7a624f9875d86391d4510617f574a306af51e15 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 11:43:48 +0200 Subject: [PATCH 5/9] Fix button style --- packages/gitbook/src/components/AIActions/AIActions.tsx | 2 +- packages/gitbook/src/components/AIActions/AIActionsDropdown.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 714f526e11..7e71504c2a 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -238,7 +238,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} 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" /> } > From a4a7f280caa46fe8f16c8426ebc8b2ff6a9f5432 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 12:55:11 +0200 Subject: [PATCH 6/9] Fix state --- .../gitbook/src/components/AIActions/AIActions.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 7e71504c2a..fbd5a50c69 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -61,7 +61,7 @@ type CopiedStore = { // We need to store everything in a store to share the state between every instance of the component. const useCopiedStore = create< CopiedStore & { - setState: (partial: Partial) => void; + setLoading: (loading: boolean) => void; copyWithTimeout: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void; } >((set) => { @@ -70,7 +70,7 @@ const useCopiedStore = create< return { copied: false, loading: false, - setState: (partial: Partial) => set((state) => ({ ...state, ...partial })), + setLoading: (loading: boolean) => set({ loading }), copyWithTimeout: async (props, opts) => { const { markdown } = props; const { onSuccess } = opts || {}; @@ -112,16 +112,16 @@ export function CopyMarkdown(props: { const closeDropdown = useDropdownMenuClose(); - const { copied, loading, setState, copyWithTimeout } = useCopiedStore(); + const { copied, loading, setLoading, copyWithTimeout } = useCopiedStore(); // Fetch the markdown from the page const fetchMarkdown = async () => { - setState({ loading: true }); + setLoading(true); const result = await fetch(markdownPageUrl).then((res) => res.text()); markdownCache.set(markdownPageUrl, result); - setState({ loading: false }); + setLoading(false); return result; }; From 3da5403eeb2a5918f386211a38e3c13c75723c54 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 13:07:17 +0200 Subject: [PATCH 7/9] rename copyWithTimeout to copy --- packages/gitbook/src/components/AIActions/AIActions.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index fbd5a50c69..66534ec728 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -62,7 +62,7 @@ type CopiedStore = { const useCopiedStore = create< CopiedStore & { setLoading: (loading: boolean) => void; - copyWithTimeout: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void; + copy: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void; } >((set) => { let timeoutRef: ReturnType | null = null; @@ -71,7 +71,7 @@ const useCopiedStore = create< copied: false, loading: false, setLoading: (loading: boolean) => set({ loading }), - copyWithTimeout: async (props, opts) => { + copy: async (props, opts) => { const { markdown } = props; const { onSuccess } = opts || {}; @@ -112,7 +112,7 @@ export function CopyMarkdown(props: { const closeDropdown = useDropdownMenuClose(); - const { copied, loading, setLoading, copyWithTimeout } = useCopiedStore(); + const { copied, loading, setLoading, copy } = useCopiedStore(); // Fetch the markdown from the page const fetchMarkdown = async () => { @@ -133,7 +133,7 @@ export function CopyMarkdown(props: { e.preventDefault(); } - copyWithTimeout( + copy( { markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), }, From f41449a5dece84d5931070163f808375ffd19240 Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 13:10:01 +0200 Subject: [PATCH 8/9] update copy function --- .../src/components/AIActions/AIActions.tsx | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/gitbook/src/components/AIActions/AIActions.tsx b/packages/gitbook/src/components/AIActions/AIActions.tsx index 66534ec728..ab0743ad75 100644 --- a/packages/gitbook/src/components/AIActions/AIActions.tsx +++ b/packages/gitbook/src/components/AIActions/AIActions.tsx @@ -62,7 +62,7 @@ type CopiedStore = { const useCopiedStore = create< CopiedStore & { setLoading: (loading: boolean) => void; - copy: (props: { markdown: string }, opts?: { onSuccess?: () => void }) => void; + copy: (data: string, opts?: { onSuccess?: () => void }) => void; } >((set) => { let timeoutRef: ReturnType | null = null; @@ -71,15 +71,14 @@ const useCopiedStore = create< copied: false, loading: false, setLoading: (loading: boolean) => set({ loading }), - copy: async (props, opts) => { - const { markdown } = props; + copy: async (data, opts) => { const { onSuccess } = opts || {}; if (timeoutRef) { clearTimeout(timeoutRef); } - await navigator.clipboard.writeText(markdown); + await navigator.clipboard.writeText(data); set({ copied: true }); @@ -133,19 +132,14 @@ export function CopyMarkdown(props: { e.preventDefault(); } - copy( - { - markdown: markdownCache.get(markdownPageUrl) || (await fetchMarkdown()), + 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(); + } }, - { - 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 ( From 5ce9748c7a34c1db6273cfffc9f347197300e24c Mon Sep 17 00:00:00 2001 From: Nolann Biron Date: Tue, 15 Jul 2025 13:28:35 +0200 Subject: [PATCH 9/9] useCallback for useDropdownMenuClose --- packages/gitbook/src/components/primitives/DropdownMenu.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/gitbook/src/components/primitives/DropdownMenu.tsx b/packages/gitbook/src/components/primitives/DropdownMenu.tsx index b48a44ef6a..ef3658474d 100644 --- a/packages/gitbook/src/components/primitives/DropdownMenu.tsx +++ b/packages/gitbook/src/components/primitives/DropdownMenu.tsx @@ -2,7 +2,7 @@ import { Icon } from '@gitbook/icons'; import type { DetailedHTMLProps, HTMLAttributes } from 'react'; -import { createContext, useContext, useState } from 'react'; +import { createContext, useCallback, useContext, useState } from 'react'; import { type ClassValue, tcls } from '@/lib/tailwind'; @@ -209,5 +209,5 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React export function useDropdownMenuClose() { const context = useContext(DropdownMenuContext); assert(context, 'DropdownMenuContext not found'); - return () => context.setOpen(false); + return useCallback(() => context.setOpen(false), [context]); }