Compare commits

..

1 Commits

Author SHA1 Message Date
Tomek Gargula 91a1e1672a Track ask AI view trigger separately from interaction events
Changes the analytics to distinguish between hover/selection trigger views and actual user interactions (clicks). This allows us to see how many users hover/select text with the Ask AI button visible versus how many actually engage with it.
2026-09-29 14:53:04 +02:00
33 changed files with 130 additions and 290 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Track which Ask AI entry point opened the chat in ask view analytics.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Recolor text over a background page cover in the first frame after hydration.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix page cover image jumping on load
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix inline Ask AI inputs and buttons doing nothing before search is opened.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Send the previous page's URL with site insights events so broken links can be traced to the page linking to them.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix Loom videos staying blank on pages with many Loom embeds by lazy-loading them.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Improve dark theme contrast for Mermaid edge labels.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Scroll the table of contents to the active page after client-side navigation.
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the first item of a sidebar page group sometimes appearing cut off after navigating.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Fix the site auth login link sometimes redirecting back to an internal URL after login.
-5
View File
@@ -1,5 +0,0 @@
---
"gitbook": patch
---
Point the "Powered by GitBook" trademark link to gitbook.com/powered-by.
+7 -6
View File
@@ -129,8 +129,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.6",
"@opennextjs/cloudflare": "1.20.7",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
@@ -346,6 +346,7 @@
},
"overrides": {
"@codemirror/state": "6.4.1",
"@opennextjs/aws": "4.1.5",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"axios": "1.8.4",
@@ -355,7 +356,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.203.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -727,7 +728,7 @@
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@7.2.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "7.2.0" } }, "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q=="],
"@gitbook/api": ["@gitbook/api@0.203.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-cSFUMM7cIMTHW9cdMpexGSkHHaXmc506fx+LQE2+KLqce8GlBDU/ElIP4RU0MGtN+SSYF31AUgvPD8ZerBVcMA=="],
"@gitbook/api": ["@gitbook/api@0.202.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-qhrjEQbNNmCljR0AgP79+BsVh9yelh9TIBg9X3TyYx5a3lJwDiT3sw1ND9DFQSxGHp8hH5w+20KJHn/IDpTbaw=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
@@ -943,9 +944,9 @@
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.6", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6" }, "bin": { "open-next": "dist/index.js" } }, "sha512-J5mzpWo6duetc3ZtcyBxrUqtD6Dwh8msRcMTNQdvpsgTUjEFp03AJmSOnJ/7BKkuF791YTQgjk4iJYIIYLmKqA=="],
"@opennextjs/aws": ["@opennextjs/aws@4.1.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@aws-sdk/client-cloudfront": "3.984.0", "@aws-sdk/client-dynamodb": "3.984.0", "@aws-sdk/client-lambda": "3.984.0", "@aws-sdk/client-s3": "3.984.0", "@aws-sdk/client-sqs": "3.984.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.3", "aws4fetch": "^1.0.20", "chalk": "^5.6.2", "cookie": "^1.0.2", "esbuild": "0.25.4", "express": "^5.1.0", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.1.0", "yaml": "^2.8.1" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3" }, "bin": { "open-next": "dist/index.js" } }, "sha512-41LNGtS5R5SCfn9zFxHx9QKJIRnmf1uZH+jonHkUMlRKER0bSmUSGjK6GJF7e50XdLXFpTHzaqgRvGmzvr9Btw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.7", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.6", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.26 <16 || >=16.3.6", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-obZ5l96+MUmGqKd7akJlT9tv0XAvaUDDJj13MyGTVTzA+lbal8kNRoUwei08rtu1Gv+anASAwwMP3dvzcZx+Jw=="],
"@opennextjs/cloudflare": ["@opennextjs/cloudflare@1.20.5", "", { "dependencies": { "@ast-grep/napi": "^0.40.5", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "4.1.3", "ci-info": "^4.2.0", "cloudflare": "^4.4.1", "comment-json": "^4.5.1", "enquirer": "^2.4.1", "glob": "^12.0.0", "ts-tqdm": "^0.8.6", "yargs": "^18.0.0" }, "peerDependencies": { "next": ">=15.5.24 <16 || >=16.3.3", "rclone.js": "^0.6.6", "wrangler": "^4.125.0" }, "optionalPeers": ["rclone.js"], "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }, "sha512-Y4qCnHTYMa8waEHvf7iqDJuiresuREp0zithSZ8hT33Wn9WyvOUMBM0SWNH961Wv7ynHRdxAJ0ggmnQ3aSdzdg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+3 -2
View File
@@ -19,7 +19,8 @@
"react": "catalog:",
"react-dom": "catalog:",
"esbuild": "0.27.3",
"axios": "1.8.4"
"axios": "1.8.4",
"@opennextjs/aws": "4.1.5"
},
"private": true,
"scripts": {
@@ -48,7 +49,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.203.0",
"@gitbook/api": "0.202.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+2 -2
View File
@@ -20,8 +20,8 @@
"@gitbook/react-openapi": "workspace:*",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@modelcontextprotocol/sdk": "1.17.5",
"@opennextjs/aws": "4.1.6",
"@opennextjs/cloudflare": "1.20.7",
"@opennextjs/aws": "4.1.3",
"@opennextjs/cloudflare": "1.20.5",
"@panzoom/panzoom": "^4.6.1",
"@sindresorhus/fnv1a": "^3.1.0",
"@tailwindcss/container-queries": "^0.1.1",
+8 -11
View File
@@ -7,7 +7,7 @@ import type { CustomizationAIMode } from '@gitbook/api';
import type { GitBookAssistant } from '@gitbook/browser-types';
import { Icon, type IconName } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '.';
import { type AskAITrigger, useAIChatController, useAIChatState } from '.';
import { AIChatIcon, AISearchIcon, getAIChatName } from '../AIChat';
import { useIntegrationAssistants } from '../Integrations';
import { useSetSearchState } from '../Search/useSearch';
@@ -26,7 +26,8 @@ export type AIConfig = {
};
};
export type Assistant = Omit<GitBookAssistant, 'icon'> & {
export type Assistant = Omit<GitBookAssistant, 'icon' | 'open'> & {
open: (query?: string, trigger?: AskAITrigger) => void;
/**
* Unique identifier for the assistant. Generated automatically using Crypto.randomUUID().
* @example '123e4567-e89b-12d3-a456-426614174000'
@@ -102,8 +103,8 @@ export function useAI(): AIContext {
className="size-text-lg"
/>
),
open: (query?: string) => {
chatController.open();
open: (query?: string, trigger?: AskAITrigger) => {
chatController.open(trigger);
if (query) {
chatController.postMessage({ message: query });
}
@@ -119,13 +120,9 @@ export function useAI(): AIContext {
icon: <AISearchIcon />,
open: (query?: string) => {
if (query) {
setSearchState((prev) => ({
...prev,
query: null,
ask: query,
scope: prev?.scope ?? 'default',
open: true,
}));
setSearchState((prev) =>
prev ? { ...prev, query: null, ask: query, open: true } : null
);
}
},
pageAction: false,
@@ -13,7 +13,7 @@ import {
SiteInsightsDisplayContext,
} from '@gitbook/api';
import { getInsightsSession, useTrackEvent } from '../Insights';
import { getInsightsSession, trackAskView, useTrackEvent } from '../Insights';
import { type UpdateSearchState, useSetSearchState } from '../Search';
import { addRecentSearchQuery } from '../Search/recent-queries';
import type { AnyAIControl } from './controls';
@@ -60,6 +60,8 @@ export type AIChatStatus =
| 'error'
| 'confirm';
export type AskAITrigger = 'hover' | 'selection' | 'site' | 'page' | 'embed';
export type AIChatState = {
/**
* If true, the chat is open.
@@ -160,7 +162,7 @@ type AIChatEventListener = (input?: Omit<AIChatEvent, 'type'>) => void;
export type AIChatController = {
/** Open the dialog */
open: () => void;
open: (trigger?: AskAITrigger) => void;
/** Close the dialog */
close: () => void;
/** Post a message to the session */
@@ -302,22 +304,28 @@ export function AIChatProvider(props: {
const eventsRef = React.useRef<Map<AIChatEvent['type'], AIChatEventListener[]>>(new Map());
// Open AI chat and sync with search state
const onOpen = React.useCallback(() => {
setIntercomLauncherHidden(true);
const onOpen = React.useCallback(
(trigger?: AskAITrigger) => {
setIntercomLauncherHidden(true);
if (!renderMessageOptions?.asEmbeddable) {
trackAskView(trackEvent, trigger);
}
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
const { initialQuery } = globalState.getState();
globalState.setState((state) => ({ ...state, opened: true }));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
// Update search state to show ask mode with first message or current ask value
setSearchState((prev) => ({
ask: prev?.ask ?? initialQuery ?? '',
query: prev?.query ?? null,
scope: prev?.scope ?? 'default',
open: false, // Close search popover when opening chat
}));
notify(eventsRef.current.get('open'), {});
}, [setSearchState]);
notify(eventsRef.current.get('open'), {});
},
[setSearchState, trackEvent, renderMessageOptions?.asEmbeddable]
);
// Close AI chat and clear ask parameter
const onClose = React.useCallback(() => {
@@ -24,7 +24,6 @@ import {
EmbeddableFrameTitle,
} from '../Embeddable/EmbeddableFrame';
import { useIsMounted, useNow } from '../hooks';
import { useTrackEvent } from '../Insights';
import { Button } from '../primitives';
import { ScrollContainer } from '../primitives/ScrollContainer';
import { SideSheet } from '../primitives/SideSheet';
@@ -68,16 +67,6 @@ export function AIChat() {
}
);
// Track the view of the AI chat
const trackEvent = useTrackEvent();
React.useEffect(() => {
if (chat.opened) {
trackEvent({
type: 'ask_view',
});
}
}, [chat.opened, trackEvent]);
return (
<SideSheet
side="right"
@@ -39,7 +39,7 @@ export function AIChatButton(props: {
</div>
}
aria-label={tString(language, 'ai_chat_ask', assistant.label)}
onClick={() => assistant.open()}
onClick={() => assistant.open(undefined, 'site')}
>
{showLabel ? t(language, 'ask') : null}
</Button>
@@ -33,7 +33,7 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
id: `text-${fnv1a(text, { size: 32 })}`,
content: text,
});
chatController.open();
chatController.open('hover');
chatController.setDraft(tString(language, 'ai_chat_paragraph_draft'));
chatController.focus();
};
@@ -84,7 +84,7 @@ export function AskAITextSelection() {
id: `text-${fnv1a(content, { size: 32 })}`,
content,
});
chatController.open();
chatController.open('selection');
chatController.focus();
clear();
};
@@ -247,15 +247,11 @@ async function renderMermaidDiagram(args: {
const { source, id, darkMode, mermaidRuntimeURL } = args;
const { mermaid } = await loadMermaid(mermaidRuntimeURL);
// Mermaid's default dark edge label pill only reaches 4.43:1 contrast, below WCAG AA.
const themeVariables = darkMode ? { edgeLabelBackground: '#3a3a3a' } : undefined;
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
darkMode,
theme: darkMode ? 'dark' : undefined,
themeVariables,
});
const renderContainer = createMermaidRenderContainer();
@@ -37,10 +37,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
<>
<div
dangerouslySetInnerHTML={{
__html:
context.mode !== 'print' && shouldLazyLoad(block.data.url)
? lazyLoadIframes(embed.html)
: embed.html,
__html: embed.html,
}}
data-visual-test="blackout"
/>
@@ -76,23 +73,6 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
);
}
/**
* Pages with many Loom embeds exhaust the browser's request budget
* (ERR_INSUFFICIENT_RESOURCES) when every player loads at once, leaving some blank.
*/
function lazyLoadIframes(html: string): string {
return html.replace(/<iframe\b(?![^>]*\bloading=)/gi, '<iframe loading="lazy"');
}
function shouldLazyLoad(url: string): boolean {
try {
const { hostname } = new URL(url);
return hostname === 'loom.com' || hostname.endsWith('.loom.com');
} catch {
return false;
}
}
/**
* Create an integration block with an unfurl action from the GitBook Embed response.
*/
@@ -4,7 +4,7 @@ import React from 'react';
import * as api from '@gitbook/api';
import { useTrackEvent } from '../Insights';
import { trackAskView, useTrackEvent } from '../Insights';
import { LinkContext } from '../primitives';
import { useIsVisible } from '../VisibilityContext';
import {
@@ -60,15 +60,10 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
return;
}
trackEvent(
{
type: 'ask_view',
},
{
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
}
);
trackAskView(trackEvent, 'embed', {
pageId: null,
displayContext: api.SiteInsightsDisplayContext.Embed,
});
}, [trackEvent, isVisible]);
const tabsRef = React.useRef<HTMLDivElement>(null);
@@ -4,9 +4,9 @@ import * as React from 'react';
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
import type * as api from '@gitbook/api';
import { SiteInsightsDisplayContext } from '@gitbook/api';
import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
import type { AskAITrigger } from '../AI';
import { type CurrentContentContext, useCurrentContent } from '../hooks';
import { getSession } from './sessions';
import { type VisitorResponse, useVisitor } from './visitorId';
@@ -43,7 +43,7 @@ export type TrackEventInput<EventName extends InsightsEventName = InsightsEventN
/**
* Callback to track an event.
*/
type TrackEventCallback = <EventName extends InsightsEventName>(
export type TrackEventCallback = <EventName extends InsightsEventName>(
event: TrackEventInput<EventName>,
ctx?: InsightsEventPageContext,
options?: InsightsEventOptions
@@ -76,15 +76,12 @@ export function InsightsProvider(props: InsightsProviderProps) {
[pathname: string]:
| {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext?: InsightsEventPageContext;
}
| undefined;
}>({});
// Href of the last page an event was tracked on; `undefined` until the first event.
const lastUrlRef = React.useRef<string | null | undefined>(undefined);
/**
* Synchronously flush all the pending events.
@@ -109,7 +106,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
allEvents.push(
...transformEvents({
url: eventsForPathname.url,
previousUrl: eventsForPathname.previousUrl,
events: eventsForPathname.events,
context: currentContent,
pageContext: eventsForPathname.pageContext,
@@ -159,9 +155,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
) => {
const pathname = window.location.pathname;
const previous = eventsRef.current[pathname];
const lastUrl =
lastUrlRef.current === undefined ? getSameOriginReferrer() : lastUrlRef.current;
lastUrlRef.current = window.location.href;
eventsRef.current[pathname] = {
// An explicitly-provided context wins so page-scoped events (e.g. feedback) can
// attribute to their page even when the pathname's ambient context has none — such
@@ -169,7 +162,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
// context keep the stored one.
pageContext: ctx ?? previous?.pageContext,
url: previous?.url ?? window.location.href,
previousUrl: previous ? previous.previousUrl : lastUrl,
events: [
...(previous?.events ?? []),
{
@@ -223,16 +215,14 @@ export function useTrackEvent(): TrackEventCallback {
return React.useContext(InsightsContext);
}
/**
* The referrer when it's another page of this origin, so a full page load (e.g. an absolute link
* to a missing page) still records the page it came from.
*/
function getSameOriginReferrer(): string | null {
if (document.referrer === window.location.href || !URL.canParse(document.referrer)) {
return null;
}
const referrer = new URL(document.referrer);
return referrer.origin === window.location.origin ? referrer.href : null;
/** Track an ask view while the frontend uses an API type that predates its trigger field. */
export function trackAskView(
trackEvent: TrackEventCallback,
trigger: AskAITrigger | undefined,
ctx?: InsightsEventPageContext
) {
// TODO: remove this cast once @gitbook/api includes SiteInsightsEventAskView.trigger.
trackEvent({ type: 'ask_view', trigger } as unknown as TrackEventInput<'ask_view'>, ctx);
}
/**
@@ -261,7 +251,6 @@ function sendEvents(args: { eventUrl: string; events: api.SiteInsightsEvent[] })
*/
function transformEvents(input: {
url: string;
previousUrl: string | null;
events: TrackEventInput<InsightsEventName>[];
context: CurrentContentContext;
pageContext: InsightsEventPageContext;
@@ -280,11 +269,6 @@ function transformEvents(input: {
const location: api.SiteInsightsEventLocation = {
url: input.url,
// The embed's navigation is between its own tabs, not pages of the site.
previousUrl:
input.pageContext.displayContext === SiteInsightsDisplayContext.Embed
? null
: input.previousUrl,
siteSection: input.context.siteSectionId ?? null,
siteSpace: input.context.siteSpaceId ?? null,
space: input.context.spaceId,
@@ -67,7 +67,7 @@ export function ActionOpenAssistant(props: {
href: page.href,
});
}
assistant.open();
assistant.open(undefined, 'page');
}}
/>
);
@@ -29,21 +29,18 @@ interface PageCoverImageProps {
export function PageCoverImage(props: PageCoverImageProps) {
const { imgs, y, height, mask } = props;
const { objectPositionY, isLoading } = useCoverPosition(imgs, y, {
height,
aspectRatio: PAGE_COVER_SIZE,
});
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
if (isLoading) {
return (
<div className="h-full w-full overflow-hidden">
<div className="h-full w-full overflow-hidden" ref={containerRef}>
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
</div>
);
}
return (
<div className="h-full w-full overflow-hidden @container" style={{ height }}>
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
<img
src={imgs.light.src}
srcSet={imgs.light.srcSet}
@@ -55,7 +52,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -76,7 +73,7 @@ export function PageCoverImage(props: PageCoverImageProps) {
aspectRatio: height
? undefined
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
objectPosition: `50% ${objectPositionY}`,
objectPosition: `50% ${objectPositionY}%`,
height, // if no height is passed, no height will be set.
maskComposite: 'intersect',
maskImage:
@@ -1,5 +1,6 @@
'use client';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useResizeObserver } from 'usehooks-ts';
interface ImageSize {
width: number;
@@ -21,18 +22,19 @@ interface Images {
}
/**
* Hook to compute the CSS object position Y for a cover image, from the y offset and the image
* dimensions. The container must set `container-type: inline-size` and be as wide as the image,
* since the position is computed against its width.
* Hook to calculate the object position Y percentage for a cover image
* based on the y offset, image dimensions, and container dimensions.
*/
export function useCoverPosition(
imgs: Images,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
) {
export function useCoverPosition(imgs: Images, y: number) {
const containerRef = useRef<HTMLDivElement>(null);
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
const container = useResizeObserver({
// @ts-expect-error wrong types
ref: containerRef,
});
// Load original image dimensions if not provided in `imgs`
useLayoutEffect(() => {
// Check if we have dimensions from dark (if provided) or else the default light.
@@ -66,31 +68,42 @@ export function useCoverPosition(
// Check dark first, then light, then loaded dimensions
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
// Calculate ratio and dimensions similar to useCoverPosition hook
const ratio =
imageDimensions && container.height && container.width
? Math.max(
container.width / imageDimensions.width,
container.height / imageDimensions.height
)
: 1;
const safeRatio = ratio || 1;
const scaledHeight =
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
const maxOffset =
scaledHeight && container.height
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
: 0;
// Parse the position between the allowed min/max
const objectPositionY = useMemo(() => {
if (!container.height || !imageDimensions) {
return 50;
}
const scaled = imageDimensions.height * safeRatio;
if (scaled <= container.height || maxOffset === 0) {
return 50;
}
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
return relative * 100;
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
return {
objectPositionY: imageDimensions
? getCoverObjectPositionY(imageDimensions, y, container)
: '50%',
containerRef,
objectPositionY,
isLoading: !imageDimensions || isLoading,
};
}
/**
* Offset the image `y` natural pixels from centered, clamped so it keeps covering the container.
* Expressed in CSS against the container width (`cqw`), so it renders the same on the server as
* after hydration, without measuring the container.
*/
function getCoverObjectPositionY(
image: ImageSize,
y: number,
container: { height: number | undefined; aspectRatio: ImageSize }
): string {
const containerHeight = container.height
? `${container.height}px`
: `${(100 * container.aspectRatio.height) / container.aspectRatio.width}cqw`;
// Rendered height of the image under `object-fit: cover`.
const scaledHeight = `max(${(100 * image.height) / image.width}cqw, ${containerHeight})`;
const maxOffset = `(${scaledHeight} - ${containerHeight}) / 2`;
const offset = `${scaledHeight} * ${y / image.height}`;
return `calc(50% + clamp(-1 * ${maxOffset}, ${offset}, ${maxOffset}))`;
}
@@ -4,7 +4,6 @@ import { usePathname, useSearchParams } from 'next/navigation';
import { useMemo } from 'react';
import type React from 'react';
import { useIsMounted } from '../hooks/useIsMounted';
import { Button, type ButtonProps } from '../primitives/Button';
import { DropdownMenuItem } from '../primitives/DropdownMenu';
import { Link, type LinkInsightsProps, type LinkProps } from '../primitives/Link';
@@ -17,17 +16,9 @@ function useSiteAuthLoginHrefWithLocation(href: string) {
const rawPathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams?.toString();
// On the server, usePathname() returns the internal rewritten route (/sites/…, which includes
// the site API token), so the location is only added once mounted.
// https://nextjs.org/docs/app/api-reference/functions/use-pathname#avoid-hydration-mismatch-with-rewrites
const isMounted = useIsMounted();
const pathname = isMounted ? (rawPathname ?? '/') : null;
const pathname = rawPathname ?? '/';
return useMemo(() => {
if (pathname === null) {
return href;
}
const baseURL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
const resolved = URL.canParse(href) ? new URL(href) : new URL(href, baseURL);
const siteBasePath = removeTrailingSlash(
@@ -82,7 +82,7 @@ type CoverOverlap =
* `data-over-cover="split"` with the crossing point in `--cover-edge`.
*/
function useMarkTextOverCover() {
React.useLayoutEffect(() => {
React.useEffect(() => {
const root = document.documentElement;
const pageCover = document.querySelector<HTMLElement>('[data-gb-page-cover]');
@@ -150,7 +150,7 @@ function useMarkTextOverCover() {
});
};
update();
scheduleUpdate();
window.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate, { passive: true });
@@ -29,8 +29,7 @@ export function PageGroupItem(props: { page: ClientTOCPageGroup; isFirst?: boole
<div ref={sentinelRef} className="h-0" aria-hidden="true" />
<div
className={tcls(
// No opacity transition on ::after: in Chrome it leaves stale pixels over the first child after the list scrolls.
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent',
'-top-4 sticky z-1 after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-linear-to-b after:from-tint-base after:to-transparent after:transition-opacity',
isSticking ? '' : 'after:opacity-0',
'mt-1 pt-2.5 pb-0',
'bg-tint-base',
@@ -134,7 +134,6 @@ export async function TableOfContents(props: {
orientation="vertical"
contentClassName="flex flex-col p-2 gutter-stable"
active="[data-active=true]"
followActive
leading={{
fade: true,
button: {
@@ -19,7 +19,7 @@ export async function Trademark(
const { space } = context;
const language = await getSpaceLanguage(context);
const url = new URL('https://www.gitbook.com/powered-by');
const url = new URL('https://www.gitbook.com');
url.searchParams.set('utm_source', 'content');
url.searchParams.set('utm_medium', 'trademark');
url.searchParams.set('utm_campaign', space.id);
@@ -11,7 +11,7 @@ import { tcls } from '@/lib/tailwind';
* A container that encapsulates a scrollable area with usability features.
* - Faded edges when there is more content than the container can display.
* - Buttons to advance the scroll position.
* - Auto-scroll to the active item on mount and when it changes.
* - Auto-scroll to the active item when it's initially active.
*/
export type ScrollContainerProps = {
children: React.ReactNode;
@@ -42,12 +42,6 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/**
* Only scroll to the active item when it is not fully visible, and keep following it
* when it changes later (requires `active` to be a selector).
*/
followActive?: boolean;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
@@ -59,7 +53,6 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
followActive = false,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
@@ -87,44 +80,8 @@ export function ScrollContainer(props: ScrollContainerProps) {
if (!activeItem || !container.contains(activeItem)) {
return;
}
if (followActive && isElementFullyVisibleInContainer(activeItem, container)) {
return;
}
scrollToElementInContainer(activeItem, container);
}, [active, followActive]);
React.useEffect(() => {
const container = containerRef.current;
if (!followActive || !container || typeof active !== 'string') {
return;
}
let frame = 0;
// Active items can mount only after a collapsed group expands.
const observer = new MutationObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
for (const activeItem of container.querySelectorAll(active)) {
if (!isElementFullyVisibleInContainer(activeItem, container)) {
scrollToElementInContainer(activeItem, container, 'smooth');
return;
}
}
});
});
observer.observe(container, {
attributes: true,
attributeFilter: ['data-active'],
childList: true,
subtree: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [active, followActive]);
}, [active]);
const scrollFurther = () => {
const container = containerRef.current;
@@ -378,11 +335,7 @@ function scrollByViewport(
/**
* Scroll to an element in a container.
*/
export function scrollToElementInContainer(
element: Element,
container: HTMLElement,
behavior: ScrollBehavior = 'auto'
) {
function scrollToElementInContainer(element: Element, container: HTMLElement) {
const containerRect = container.getBoundingClientRect();
const rect = element.getBoundingClientRect();
@@ -397,26 +350,8 @@ export function scrollToElementInContainer(
(rect.left - containerRect.left) -
container.clientWidth / 2 +
rect.width / 2,
behavior,
// Use 'auto' to avoid additional scroll animations when scrolling to an element
// as this may be called during layout/initialization when the page is not fully loaded.
behavior: 'auto',
});
}
function isElementFullyVisibleInContainer(element: Element, container: HTMLElement) {
if (
!element.getClientRects().length ||
container.clientHeight === 0 ||
container.clientWidth === 0
) {
return true;
}
const containerRect = container.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
return (
elementRect.top >= containerRect.top &&
elementRect.bottom <= containerRect.bottom &&
elementRect.left >= containerRect.left &&
elementRect.right <= containerRect.right
);
}