diff --git a/.changeset/shared-ask-ai-paragraph-button.md b/.changeset/shared-ask-ai-paragraph-button.md new file mode 100644 index 000000000..392b36afc --- /dev/null +++ b/.changeset/shared-ask-ai-paragraph-button.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Render a single Ask AI paragraph button that moves to the hovered paragraph, instead of one hidden button per paragraph. diff --git a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx index 7e5a915a9..9872325ff 100644 --- a/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx +++ b/packages/gitbook/src/components/AIChat/AskAIParagraphButton/AskAIParagraphButton.tsx @@ -1,29 +1,58 @@ 'use client'; import fnv1a from '@sindresorhus/fnv1a'; +import * as React from 'react'; +import { createPortal } from 'react-dom'; import { AIChatIcon } from '../AIChatIcon'; import { useAIChatController, useAIConfig } from '@/components/AI'; import { Button } from '@/components/primitives'; +import { isAIChatEnabled } from '@/components/utils/isAIChatEnabled'; import { t, tString, useLanguage } from '@/intl/client'; -import { type ClassValue, tcls } from '@/lib/tailwind'; +import { tcls } from '@/lib/tailwind'; /** - * A small icon button revealed in the left margin of a top-level paragraph when it is hovered (on - * devices with a fine pointer). Clicking it stages the paragraph's text as a reference and opens - * the AI chat — a more discoverable variant of the text-selection "Ask" affordance. + * A small icon button revealed in the left margin of a paragraph when it is hovered (on devices + * with a fine pointer). Clicking it stages the paragraph's text as a reference and opens the AI + * chat — a more discoverable variant of the text-selection "Ask" affordance. * - * The button is absolutely positioned so it never affects the document flow, and its visibility is - * driven purely by the `group/ask-ai` hover state of its paragraph wrapper. + * A single instance is portaled into the last hovered paragraph wrapper, rather than rendering one + * per paragraph, so pages with many paragraphs don't pay for markup that is hidden until hovered. + * Its visibility is still driven purely by the `group/ask-ai` hover state of that wrapper. */ -export function AskAIParagraphButton(props: { content: string; className?: ClassValue }) { - const { content, className } = props; +export function AskAIParagraphButton() { const config = useAIConfig(); const language = useLanguage(); const chatController = useAIChatController(); + const [target, setTarget] = React.useState(null); + + const enabled = isAIChatEnabled(config.aiMode); + + React.useEffect(() => { + if (!enabled) { + return; + } + + const onPointerOver = (event: PointerEvent) => { + if (!(event.target instanceof Element)) { + return; + } + const wrapper = event.target.closest('[data-ask-ai-paragraph]'); + if (wrapper) { + setTarget(wrapper); + } + }; + + document.addEventListener('pointerover', onPointerOver); + return () => document.removeEventListener('pointerover', onPointerOver); + }, [enabled]); + + if (!enabled || !target) { + return null; + } const onClick = () => { - const text = content.trim(); + const text = getParagraphText(target).trim(); if (!text) { return; } @@ -38,7 +67,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class chatController.focus(); }; - return ( + return createPortal( + , + target ); } + +/** + * Read the paragraph's text, skipping rendered math like `getNodeText` does. + */ +function getParagraphText(wrapper: HTMLElement): string { + const paragraph = wrapper.querySelector(':scope > p'); + if (!paragraph) { + return ''; + } + + const walker = document.createTreeWalker( + paragraph, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: (node) => + node instanceof Element && + node.matches('.katex, mjx-container, [aria-hidden="true"]') + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT, + } + ); + + let text = ''; + while (walker.nextNode()) { + if (walker.currentNode.nodeType === Node.TEXT_NODE) { + text += walker.currentNode.textContent ?? ''; + } + } + return text; +} diff --git a/packages/gitbook/src/components/DocumentView/Paragraph.tsx b/packages/gitbook/src/components/DocumentView/Paragraph.tsx index 4d80132fd..31aca82f3 100644 --- a/packages/gitbook/src/components/DocumentView/Paragraph.tsx +++ b/packages/gitbook/src/components/DocumentView/Paragraph.tsx @@ -4,7 +4,6 @@ import { CustomizationAIMode } from '@gitbook/api'; import type { BlockProps } from './Block'; import { Inlines } from './Inlines'; import { getTextAlignment } from './utils'; -import { AskAIParagraphButton } from '@/components/AIChat/AskAIParagraphButton'; import { getNodeText } from '@/lib/document'; import { tcls } from '@/lib/tailwind'; @@ -28,7 +27,7 @@ export function Paragraph(props: BlockProps) { const contentContext = context.contentContext; const aiAssistantEnabled = context.mode !== 'print' && - // The button is hidden in table cells, and big tables can hold thousands of paragraphs. + // Table cells would clip the margin button, and big tables hold thousands of paragraphs. !ancestorBlocks.some((ancestor) => ancestor.type === 'table') && contentContext != null && 'customization' in contentContext && @@ -40,6 +39,8 @@ export function Paragraph(props: BlockProps) { // `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages. return (
) { )} > {paragraph} -
); } diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx index 645dd450f..1e56d4c6d 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableRootLayout.tsx @@ -1,6 +1,7 @@ import type { CustomizationDefaultThemeMode } from '@gitbook/api'; import { SiteInsightsTrademarkPlacement } from '@gitbook/api'; +import { AskAIParagraphButton } from '../AIChat'; import { LoadIntegrations } from '../Integrations'; import { NavigationLoader } from '../primitives/NavigationLoader'; import { SpaceLayoutServerContext } from '../SpaceLayout'; @@ -77,6 +78,7 @@ export async function EmbeddableRootLayout({ }} > + {children} {context.customization.trademark.enabled ? ( diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 6433172d1..bcfb72aec 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -5,7 +5,7 @@ import { CustomizationHeaderPreset, CustomizationSearchStyle } from '@gitbook/ap import { AdaptiveVisitorContextProvider } from '../Adaptive'; import { AIChatProvider } from '../AI'; import type { RenderAIMessageOptions } from '../AI'; -import { AIChat, AskAITextSelection } from '../AIChat'; +import { AIChat, AskAIParagraphButton, AskAITextSelection } from '../AIChat'; import { Announcement } from '../Announcement'; import { SpacesDropdown, TranslationsDropdown } from '../Header/SpacesDropdown'; import { CurrentContentProvider } from '../hooks'; @@ -138,6 +138,7 @@ export function SpaceLayout(props: SpaceLayoutProps) { <> + ) : null}