Skip to content

Improvement for CopyMarkdown action #3465

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-baboons-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'gitbook': patch
---

Refactor icon loading state in AIAction components
5 changes: 4 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -2502,7 +2503,7 @@

"queue-microtask": ["[email protected]", "", {}, "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": ["[email protected]", "", { "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=="],

Expand Down Expand Up @@ -4046,6 +4047,8 @@

"cacheable-request/lowercase-keys": ["[email protected]", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],

"camelcase-keys/quick-lru": ["[email protected]", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="],

"codemirror/@codemirror/autocomplete": ["@codemirror/[email protected]", "", { "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/[email protected]", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw=="],
Expand Down
3 changes: 2 additions & 1 deletion packages/gitbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
131 changes: 71 additions & 60 deletions packages/gitbook/src/components/AIActions/AIActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof setTimeout> | 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<string, string>({ maxSize: 10 });

/**
* Copies the markdown version of the page to the clipboard.
Expand All @@ -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<ReturnType<typeof setTimeout> | 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 (
Expand Down Expand Up @@ -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}
Expand All @@ -239,21 +247,24 @@ function AIActionWrapper(props: {
href={href}
target="_blank"
onClick={onClick}
disabled={disabled}
disabled={disabled || loading}
>
{icon ? (
<div className="flex size-5 items-center justify-center text-tint">
{typeof icon === 'string' ? (
<div className="flex size-5 items-center justify-center text-tint">
{loading ? (
<Icon icon="spinner-third" className="size-4 animate-spin" />
) : icon ? (
typeof icon === 'string' ? (
<Icon
icon={icon as IconName}
iconStyle={IconStyle.Regular}
className="size-4 fill-transparent stroke-current"
/>
) : (
icon
)}
</div>
) : null}
)
) : null}
</div>

<div className="flex flex-1 flex-col gap-0.5">
<span className="flex items-center gap-2 text-tint-strong">
<span className="truncate font-medium text-sm">{label}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
}
>
Expand Down
88 changes: 53 additions & 35 deletions packages/gitbook/src/components/primitives/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,28 @@

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<E extends HTMLElement = HTMLElement> = Omit<
Partial<DetailedHTMLProps<HTMLAttributes<E>, E>>,
'ref'
>;

const DropdownMenuContext = createContext<{
open: boolean;
setOpen: (open: boolean) => void;
}>({
open: false,
setOpen: () => {},
});

/**
* Button with a dropdown.
*/
Expand Down Expand Up @@ -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 (
<RadixDropdownMenu.Root
modal={false}
open={openOnHover ? clicked || hovered : clicked}
onOpenChange={setClicked}
>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => (openOnHover ? setClicked(!clicked) : null)}
className="group/dropdown"
>
{button}
</RadixDropdownMenu.Trigger>
const isOpen = openOnHover ? open || hovered : open;

<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
return (
<DropdownMenuContext.Provider value={{ open: isOpen, setOpen }}>
<RadixDropdownMenu.Root modal={false} open={isOpen} onOpenChange={setOpen}>
<RadixDropdownMenu.Trigger
asChild
onMouseEnter={() => 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"
>
<div
className={tcls(
'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',
className
)}
{button}
</RadixDropdownMenu.Trigger>

<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
align={align}
side={side}
className="z-40 animate-scaleIn border-tint pt-2"
>
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</RadixDropdownMenu.Root>
<div
className={tcls(
'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',
className
)}
>
{children}
</div>
</RadixDropdownMenu.Content>
</RadixDropdownMenu.Portal>
</RadixDropdownMenu.Root>
</DropdownMenuContext.Provider>
);
}

Expand Down Expand Up @@ -193,3 +202,12 @@ export function DropdownSubMenu(props: { children: React.ReactNode; label: React
</RadixDropdownMenu.Sub>
);
}

/**
* Hook to close the dropdown menu.
*/
export function useDropdownMenuClose() {
const context = useContext(DropdownMenuContext);
assert(context, 'DropdownMenuContext not found');
return useCallback(() => context.setOpen(false), [context]);
}