mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Improvement for CopyMarkdown action (#3465)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'gitbook': patch
|
||||
---
|
||||
|
||||
Refactor icon loading state in AIAction components
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user