Render one shared Ask AI paragraph button (#4681)

This commit is contained in:
Peter White
2026-10-13 14:57:45 +02:00
committed by GitHub
parent a625112106
commit 03aa0f75f5
5 changed files with 84 additions and 22 deletions
@@ -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.
@@ -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<HTMLElement | null>(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<HTMLElement>('[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(
<div
className={tcls(
// Sit in the left margin, flush against the paragraph so there is no hover gap.
@@ -52,13 +81,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'invisible opacity-0 transition-opacity duration-150',
'hover:visible hover:opacity-100 group-hover/ask-ai:visible group-hover/ask-ai:opacity-100',
// Never shown on touch / hover-less contexts.
'not-pointer-fine:hidden',
// Hidden where an overflow-clipped ancestor would cut it off (ARIA and native
// tables, record cards).
'in-[[role=table]]:hidden',
'in-[table]:hidden',
'in-[[data-card]]:hidden',
className
'not-pointer-fine:hidden'
)}
>
<Button
@@ -75,6 +98,37 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'in-[.hint.bg-danger]:bg-danger in-[.hint.bg-info]:bg-info in-[.hint.bg-success]:bg-success in-[.hint.bg-warning]:bg-warning'
)}
/>
</div>
</div>,
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;
}
@@ -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<DocumentBlockParagraph>) {
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<DocumentBlockParagraph>) {
// `self-center`/`self-end`) — otherwise centered paragraphs pin left on wide/no-TOC pages.
return (
<div
// The shared `AskAIParagraphButton` portals itself into the hovered wrapper.
data-ask-ai-paragraph=""
className={tcls(
'group/ask-ai relative',
style,
@@ -47,7 +48,6 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
)}
>
{paragraph}
<AskAIParagraphButton content={text} />
</div>
);
}
@@ -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({
}}
>
<NavigationLoader />
<AskAIParagraphButton />
<VisibilityProvider className="fixed inset-0 flex flex-col">
{children}
{context.customization.trademark.enabled ? (
@@ -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) {
<>
<AIChat />
<AskAITextSelection />
<AskAIParagraphButton />
</>
) : null}