Improvement for CopyMarkdown action (#3465)

This commit is contained in:
Nolann B.
2025-07-15 13:41:25 +02:00
committed by GitHub
parent 334cfdd522
commit 9ee9082614
6 changed files with 136 additions and 98 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Refactor icon loading state in AIAction components
+4 -1
View File
@@ -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=="],
+2 -1
View File
@@ -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",
@@ -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<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.
@@ -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 (
@@ -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 ? (
<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}
@@ -251,9 +261,10 @@ function AIActionWrapper(props: {
/>
) : (
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>
@@ -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"
/>
}
>
@@ -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<E extends HTMLElement = HTMLElement> = Omit<
@@ -15,6 +16,14 @@ export type DropdownButtonProps<E extends HTMLElement = HTMLElement> = 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);
const isOpen = openOnHover ? open || hovered : open;
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>
<RadixDropdownMenu.Portal>
<RadixDropdownMenu.Content
data-testid="dropdown-menu"
hideWhenDetached
collisionPadding={8}
<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>
);
}
@@ -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]);
}