Merge branch 'main' into peter/rnd-12709-support-permanent-308-site-redirects

This commit is contained in:
Peter White
2026-09-07 12:25:33 +02:00
committed by GitHub
43 changed files with 2160 additions and 193 deletions
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Simplify carousel overflow with symmetric edge masks and visible-item paging. Replaces complex negative-margin bleed logic with transparent edge fades and page-by-visible-item scrolling.
+5
View File
@@ -0,0 +1,5 @@
---
'gitbook': patch
---
Fix inline Ask AI buttons opening a configured custom assistant.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix breadcrumbs for cross-space page links in grouped sites.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Bump `@gitbook/api` to 0.199.0, and record a markdown request made from the page actions menu as a page action rather than an agent request.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the page-actions dropdown closing before the "Copied" confirmation could be shown when copying the MCP server URL, an MCP install command, or the page as Markdown.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Move paragraph block styles behind a single `paragraph` class and drop the `page-cover-background:` gate from the cover-contrast text. The gate combined with the per-paragraph `:not(:has(...))` made every DOM insertion re-style all paragraphs, which froze very long pages.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Render horizontal and vertical merged table cells on published pages.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Scroll to the top when selecting a search result for the page already being viewed.
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Add end-to-end coverage for root and nested external links in site section navigation.
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix tabs nested inside another tab group rendering an empty body once a tab in the outer group was selected.
+2 -2
View File
@@ -354,7 +354,7 @@
},
"catalog": {
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.198.0",
"@gitbook/api": "0.199.0",
"@scalar/api-client-react": "^1.3.46",
"@tsconfig/node20": "^20.1.6",
"@tsconfig/strictest": "^2.0.6",
@@ -726,7 +726,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.198.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-ZGXqYip6YFsCM5rqlJ+n8/Esw1bMNK4whqiMoWMeQgMKEX5YXVQaBuVRxU1jDEI49kup+T56xPq3I9g6Wcayng=="],
"@gitbook/api": ["@gitbook/api@0.199.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-yLxkSTXlGk7jbtThV2vpnTfqZTq6yLgMJt6jhDGqOmP0LsYAVYYtg8NPf2tXp37aF46m3Z0D908wfSQyu374cg=="],
"@gitbook/browser-types": ["@gitbook/browser-types@workspace:packages/browser-types"],
+1 -1
View File
@@ -48,7 +48,7 @@
"@tsconfig/strictest": "^2.0.6",
"@tsconfig/node20": "^20.1.6",
"@base-ui/react": "^1.7.0",
"@gitbook/api": "0.198.0",
"@gitbook/api": "0.199.0",
"@scalar/api-client-react": "^1.3.46",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
+144
View File
@@ -707,6 +707,150 @@ const testCases: TestsCase[] = [
await page.waitForURL((url) => url.pathname.includes('/sections/sections-4'));
},
},
{
name: 'Root external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootSections = page.locator('[data-gb-sections]');
const rootItems = rootSections.locator(':scope > li');
await expect(rootItems).toHaveCount(4);
await expect(rootItems.nth(0)).toContainText('Home');
await expect(rootItems.nth(1)).toContainText('Test Section Group 1');
await expect(rootItems.nth(2)).toContainText('Test Section Group 2');
await expect(rootItems.last()).toContainText('Gitbook Docs');
await expect(
rootSections.getByRole('link', { name: 'Gitbook Docs' })
).toBeVisible();
},
},
{
name: 'Root external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const rootLink = page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' });
await expect(rootLink).toBeVisible();
await expect(rootLink).toHaveAttribute('href', 'https://gitbook.com/docs');
await expect(rootLink).not.toHaveAttribute('target');
await expect(rootLink).not.toHaveAttribute('rel');
await expect(rootLink).toHaveAttribute('data-active', 'false');
await expect(rootLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'Nested external link renders in the configured position',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
const nestedItems = nestedLink
.locator('xpath=ancestor::ul[1]')
.locator(':scope > li');
await expect(nestedItems).toHaveCount(3);
await expect(nestedItems.nth(0)).toContainText('Section C');
await expect(nestedItems.nth(1)).toContainText('Section with longer title');
await expect(nestedItems.last()).toContainText('Gitbook Site');
},
},
{
name: 'Nested external link has the configured contract',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
const nestedLink = page.getByRole('link', { name: 'Gitbook Site' });
await expect(nestedLink).toBeVisible();
await expect(nestedLink).toHaveAttribute('href', 'https://gitbook.com');
await expect(nestedLink).not.toHaveAttribute('target');
await expect(nestedLink).not.toHaveAttribute('rel');
await expect(nestedLink).not.toHaveAttribute('aria-current');
},
},
{
name: 'External links use the configured window open behavior',
url: '',
screenshot: false,
run: async (page) => {
await waitForHydration(page);
const windowOpenCalls: {
url: string;
target: string;
features: string | undefined;
}[] = [];
await page.exposeFunction(
'recordExternalWindowOpen',
(url: string, target: string, features?: string) => {
windowOpenCalls.push({ url, target, features });
}
);
await page.evaluate(() => {
const recordExternalWindowOpen = (
window as unknown as {
recordExternalWindowOpen: (
url: string,
target: string,
features?: string
) => void;
}
).recordExternalWindowOpen;
window.open = ((url, target, features) => {
void recordExternalWindowOpen(
url?.toString() ?? '',
target ?? '',
features
);
return null;
}) as typeof window.open;
});
const initialURL = page.url();
await page
.locator('[data-gb-sections]')
.getByRole('link', { name: 'Gitbook Docs' })
.click();
await expect.poll(() => windowOpenCalls.length).toBe(1);
expect(windowOpenCalls[0]).toEqual({
url: 'https://gitbook.com/docs',
target: '_self',
features: undefined,
});
await expect(page).toHaveURL(initialURL);
await page
.locator('[data-gb-sections]')
.getByRole('button', { name: 'Test Section Group 2' })
.hover();
await page.getByRole('link', { name: 'Gitbook Site' }).click();
await expect.poll(() => windowOpenCalls.length).toBe(2);
expect(windowOpenCalls[1]).toEqual({
url: 'https://gitbook.com',
target: '_self',
features: undefined,
});
await expect(page).toHaveURL(initialURL);
},
},
],
},
{
+96
View File
@@ -236,3 +236,99 @@ test.describe('select syncing across groups (click-driven)', () => {
await expectGroupShows(page, 'b', other, 'go');
});
});
interface NestedSpec {
outer: string[];
inner: string[];
/** Which of the outer options hosts the nested group. */
host: string;
/**
* Emit the nested group's stylesheet before the outer one, as happens when a group with the
* same option set appears earlier on the page and its deduped sheet lands in `<head>` first.
*/
innerStyleFirst?: boolean;
}
/**
* Render a group nested inside one of another group's panes, mirroring the DOM `DynamicTabs`
* produces: panes are direct children of the element carrying the set class, and a pane's body is
* wrapped in a padding div before the nested group.
*/
async function renderNestedGroups(page: Page, spec: NestedSpec) {
const { outer, inner, host, innerStyleFirst = false } = spec;
const outerScope = selectSetClassName(outer);
const innerScope = selectSetClassName(inner);
const innerPanes = inner
.map(
(slug, index) =>
`<div data-testid="inner-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}>${slug}</div>`
)
.join('');
const innerGroup = `<div class="${innerScope}" data-select-group>${innerPanes}</div>`;
const outerPanes = outer
.map(
(slug, index) =>
`<div data-testid="outer-pane-${slug}" data-select-option="${slug}"${index === 0 ? ' data-select-default' : ''}><div>${slug}${slug === host ? innerGroup : ''}</div></div>`
)
.join('');
const styles = [generateSelectCSS(outer), generateSelectCSS(inner)];
if (innerStyleFirst) {
styles.reverse();
}
await page.setContent(
`<!doctype html><html><head>${styles.map((css) => `<style>${css}</style>`).join('')}</head><body><div class="${outerScope}" data-select-group>${outerPanes}</div></body></html>`
);
}
/**
* A group's stylesheet must resolve only its own panes. Because every pane of a nested group is also
* a descendant of the outer group, a sheet that reached descendants instead of children would hide
* the nested panes whenever an outer option was active, leaving the nested tab bar with an empty body.
*/
test.describe('select CSS visibility in nested groups', () => {
const outer = ['macos', 'windows'];
const inner = ['npm', 'yarn'];
test('shows both defaults when nothing is selected', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('keeps the nested group resolved when an outer option is activated', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves a nested group hosted by a non-default outer option', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'windows' });
await applySelection(page, ['windows']);
await expect(page.getByTestId('outer-pane-windows')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeHidden();
});
test('resolves each group against its own options', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos' });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
test('resolves the same way whichever stylesheet comes first', async ({ page }) => {
await renderNestedGroups(page, { outer, inner, host: 'macos', innerStyleFirst: true });
await applySelection(page, ['yarn', 'macos']);
await expect(page.getByTestId('outer-pane-macos')).toBeVisible();
await expect(page.getByTestId('inner-pane-yarn')).toBeVisible();
await expect(page.getByTestId('inner-pane-npm')).toBeHidden();
});
});
@@ -53,8 +53,10 @@ export function AskAIParagraphButton(props: { content: string; className?: Class
'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 (tables, record cards).
// 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
)}
@@ -1,5 +1,5 @@
'use client';
import { useAI, useAIChatController, useAIChatState } from '../AI';
import { useAI, useAIChatState } from '../AI';
import { Button, type ButtonProps, Input } from '../primitives';
import { useSetSearchState } from '../Search';
import { tString, useLanguage } from '@/intl/client';
@@ -18,17 +18,13 @@ export function InlineActionButton(
const { action, query, buttonProps } = props;
const { assistants } = useAI();
const chatController = useAIChatController();
const chatState = useAIChatState();
const setSearchState = useSetSearchState();
const language = useLanguage();
const handleSubmit = (value: string) => {
if (action === 'ask') {
chatController.open();
if (value ?? query) {
chatController.postMessage({ message: value ?? query });
}
assistants[0]?.open(value || query);
} else if (action === 'search') {
setSearchState((prev) => ({
...prev,
@@ -12,22 +12,13 @@ export function Paragraph(props: BlockProps<DocumentBlockParagraph>) {
const { block, style, ...contextProps } = props;
const { context } = contextProps;
// InlineActionButtons use flex-grow to take the available width. This requires the parent to be a flex container.
const inlineButtonStyle =
'has-[.button,input]:flex has-[.button,input]:flex-wrap has-[.button,input]:gap-2 has-[.button,input]:items-center';
const paragraph = (
<p
// Cover-aware contrast text applies only to the page body, not to documents
// rendered in overlays (search answers, AI chat) on a background-cover page.
data-cover-aware-text={context.isPageBody ? '' : undefined}
className={tcls(
// Cover-aware contrast text applies only to the page body, not to documents
// rendered in overlays (search answers, AI chat) on a background-cover page.
context.isPageBody &&
'page-cover-background:[&:not(:has(.button,input))]:text-contrast-cover',
inlineButtonStyle,
style,
getTextAlignment(block.data?.align)
)}
// Paragraph styles live in globals.css (`.paragraph`) to keep the class attribute short.
className={tcls('paragraph', style, getTextAlignment(block.data?.align))}
>
<Inlines {...contextProps} nodes={block.nodes} ancestorInlines={[]} />
</p>
@@ -0,0 +1,291 @@
import { getTableCellMerge } from './cellMerges';
import { getColumnWidth, getViewGridLayout, hasVisibleHeader } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import { StickyViewGrid } from './StickyViewGrid';
import type { TableGridViewProps } from './Table';
import { TableHoverTable } from './TableHoverTable';
import { TableSearchTableBody } from './TableSearch';
import { type VerticalAlignment, getColumnAlignment, getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
/** Semantic table renderer used when a cell spans multiple records. */
export function NativeViewGrid(props: TableGridViewProps) {
const { block, view, context, style } = props;
const { tableWidth } = getViewGridLayout({
block,
view,
mode: context.mode,
});
const tableContainerClassName = tableWidth === 'w-full' ? 'min-w-full w-fit' : tableWidth;
const withHeader = hasVisibleHeader(block, view);
const withStickyHeader = withHeader && context.mode !== 'print' && view.stickyHeader === true;
const withStickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const header = withHeader ? (
<NativeViewGridHeader
{...props}
stickyHeader={withStickyHeader}
stickyFirstColumn={withStickyFirstColumn}
className={tcls(
withStickyHeader
? [
'mb-0 border-t border-r border-l',
'group-data-[scrollable=false]/table:mb-1',
'group-data-[scrollable=false]/table:rounded-b-lg',
'group-data-[scrollable=true]/table:border-t-0',
'group-data-[scrollable=true]/table:border-x-0',
]
: undefined
)}
/>
) : undefined;
const body = (
<NativeViewGridBody
{...props}
withHeader={withHeader}
stickyFirstColumn={withStickyFirstColumn}
/>
);
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid
className={tcls(style, 'relative mx-auto grid w-full min-w-0')}
stickyHeader={withStickyHeader}
tableClassName={tableContainerClassName}
withTableRole={false}
header={header}
>
{body}
</StickyViewGrid>
);
}
return (
<div className={tcls(style, 'relative mx-auto grid w-full min-w-0')}>
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
<div className={tcls('flex flex-col', tableContainerClassName)}>
{header}
{body}
</div>
</div>
</div>
);
}
function NativeViewGridHeader(
props: TableGridViewProps & {
stickyHeader: boolean;
stickyFirstColumn: boolean;
className?: string;
}
) {
const { block, view, stickyHeader, stickyFirstColumn, className } = props;
const firstVisibleColumn = view.columns[0];
return (
<div
aria-hidden="true"
className={tcls(
'mb-1 rounded-lg border border-tint-subtle bg-tint',
stickyHeader || !stickyFirstColumn ? 'overflow-hidden' : undefined,
className
)}
>
<table className="w-full table-fixed border-separate border-spacing-0">
<NativeViewGridColumns {...props} />
<thead>
<tr>
{view.columns.map((column) => {
const definition = block.data.definition[column];
if (!definition) {
return null;
}
const isStickyFirstColumn =
stickyFirstColumn && column === firstVisibleColumn;
return (
<th
key={column}
scope="col"
className={tcls(
'relative px-3 py-2 font-medium text-sm text-tint-strong',
isStickyFirstColumn
? stickyHeader
? 'z-20 bg-tint'
: 'sticky left-0 z-20 bg-tint'
: undefined,
getColumnAlignment(definition)
)}
style={{
left:
stickyHeader && isStickyFirstColumn
? 'calc(-1 * var(--table-sticky-scroll-left, 0px))'
: undefined,
}}
title={definition.title}
>
{definition.title}
</th>
);
})}
</tr>
</thead>
</table>
</div>
);
}
function NativeViewGridBody(
props: TableGridViewProps & {
withHeader: boolean;
stickyFirstColumn: boolean;
}
) {
const { block, view, records, withHeader, stickyFirstColumn, cellMergeLayout } = props;
const firstVisibleColumn = view.columns[0];
const lastVisibleColumn = view.columns.at(-1);
const recordsById = new Map(records.map((record) => [record[0], record] as const));
const recordIndexes = new Map(records.map((record, index) => [record[0], index] as const));
return (
<TableHoverTable
className={tcls(
'w-full table-fixed border-separate border-spacing-0',
'[&>tbody>tr>td[data-table-hovered]]:bg-tint-hover',
'[&>tbody>tr+tr>td]:border-t',
'[&>tbody+tbody>tr:first-child>td]:border-t'
)}
>
<NativeViewGridColumns {...props} />
{withHeader ? (
<thead className="sr-only">
<tr>
{view.columns.map((column) => {
const definition = block.data.definition[column];
return definition ? (
<th key={column} scope="col">
{definition.title}
</th>
) : null;
})}
</tr>
</thead>
) : null}
{cellMergeLayout.recordGroups.map((recordGroup) => (
<TableSearchTableBody key={recordGroup[0]} recordIds={recordGroup}>
{recordGroup.map((recordId) => {
const record = recordsById.get(recordId);
const recordIndex = recordIndexes.get(recordId);
if (!record || recordIndex === undefined) {
return null;
}
return (
<tr key={recordId}>
{view.columns.map((column) => {
const cellMerge = getTableCellMerge(
cellMergeLayout,
recordId,
column
);
if (cellMerge && !cellMerge.isAnchor) {
return null;
}
const definition = block.data.definition[column];
if (!definition) {
return null;
}
const verticalAlignment =
getColumnVerticalAlignment(definition);
const renderedColumns = cellMerge?.merge.columns ?? [column];
const isLastColumn =
renderedColumns.at(-1) === lastVisibleColumn;
const isStickyFirstColumn =
stickyFirstColumn && column === firstVisibleColumn;
const rowSpan =
cellMerge && cellMerge.merge.rowSpan > 1
? cellMerge.merge.rowSpan
: undefined;
const colSpan =
cellMerge && cellMerge.merge.colSpan > 1
? cellMerge.merge.colSpan
: undefined;
return (
<td
key={column}
rowSpan={rowSpan}
colSpan={colSpan}
aria-rowspan={rowSpan}
aria-colspan={colSpan}
data-table-row-start={recordIndex}
data-table-row-end={
recordIndex + (cellMerge?.merge.rowSpan ?? 1) - 1
}
className={tcls(
'relative px-3 py-2 text-sm transition-colors',
!isLastColumn
? 'border-tint-subtle border-r'
: undefined,
getNativeCellVerticalAlignment(verticalAlignment),
isStickyFirstColumn
? 'sticky left-0 z-10 bg-tint-base'
: undefined
)}
>
<RecordColumnValue
{...props}
record={record}
column={column}
verticalAlignment={verticalAlignment}
/>
</td>
);
})}
</tr>
);
})}
</TableSearchTableBody>
))}
</TableHoverTable>
);
}
function NativeViewGridColumns(props: TableGridViewProps) {
const { block, view, context } = props;
const { columnWidths, autoSizedColumns, fixedColumns } = getViewGridLayout({
block,
view,
mode: context.mode,
});
return (
<colgroup>
{view.columns.map((column) => (
<col
key={column}
style={{
width: getColumnWidth({
column,
columnWidths,
autoSizedColumns,
fixedColumns,
}),
}}
/>
))}
</colgroup>
);
}
function getNativeCellVerticalAlignment(verticalAlignment: VerticalAlignment) {
switch (verticalAlignment) {
case 'self-start':
return 'align-top';
case 'self-end':
return 'align-bottom';
case 'self-center':
return 'align-middle';
}
}
@@ -1,20 +1,19 @@
import type { DocumentTableViewGrid } from '@gitbook/api';
import { getMergedCellWidth, getTableCellMerge } from './cellMerges';
import { getColumnWidth } from './layout';
import { RecordColumnValue } from './RecordColumnValue';
import type { TableRecordKV, TableViewProps } from './Table';
import type { TableGridViewProps, TableRecordKV } from './Table';
import { TableSearchRecord } from './TableSearch';
import { getColumnVerticalAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
export function RecordRow(
props: TableViewProps<DocumentTableViewGrid> & {
props: TableGridViewProps & {
record: TableRecordKV;
autoSizedColumns: string[];
fixedColumns: string[];
}
) {
const { view, record, autoSizedColumns, fixedColumns, block, context } = props;
const { view, record, autoSizedColumns, fixedColumns, block, context, cellMergeLayout } = props;
const stickyFirstColumn = context.mode !== 'print' && view.stickyFirstColumn === true;
const firstVisibleColumn = view.columns[0];
@@ -31,12 +30,22 @@ export function RecordRow(
)}
>
{view.columns.map((column) => {
const columnWidth = getColumnWidth({
column,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
});
const cellMerge = getTableCellMerge(cellMergeLayout, record[0], column);
if (cellMerge && !cellMerge.isAnchor) {
return null;
}
const renderedColumns = cellMerge?.merge.columns ?? [column];
const columnWidth = getMergedCellWidth(
renderedColumns.map((mergedColumn) =>
getColumnWidth({
column: mergedColumn,
columnWidths: context.mode === 'print' ? undefined : view.columnWidths,
autoSizedColumns,
fixedColumns,
})
)
);
const isStickyFirstColumnCell = stickyFirstColumn && column === firstVisibleColumn;
// @ts-expect-error
const verticalAlignment = getColumnVerticalAlignment(block.data.definition[column]);
@@ -45,6 +54,7 @@ export function RecordRow(
<div
key={column}
role="cell"
aria-colspan={cellMerge?.merge.colSpan}
className={tcls(
'relative flex flex-1 border-r px-3 py-2 align-middle text-sm last:border-r-0',
'border-tint-subtle',
@@ -9,6 +9,7 @@ interface StickyViewGridProps {
header?: ReactNode;
stickyHeader?: boolean;
tableClassName?: string;
withTableRole?: boolean;
children: ReactNode;
}
@@ -23,6 +24,7 @@ function DefaultHeaderScrollGrid({
className,
header,
tableClassName,
withTableRole = true,
children,
}: StickyViewGridProps) {
const resolvedTableClassName = tableClassName ?? 'w-fit';
@@ -30,7 +32,7 @@ function DefaultHeaderScrollGrid({
return (
<div className={className}>
<div
role="table"
role={withTableRole ? 'table' : undefined}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle"
>
<div className="w-full min-w-0 overflow-x-auto overflow-y-hidden overscroll-x-none border-tint-subtle">
@@ -48,6 +50,7 @@ function StickyHeaderOverlayScrollGrid({
className,
header,
tableClassName,
withTableRole = true,
children,
}: StickyViewGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
@@ -141,7 +144,7 @@ function StickyHeaderOverlayScrollGrid({
ref={rootRef}
className="group/table relative flex w-full min-w-0 max-w-full flex-col rounded-lg border-tint-subtle data-[scrollable=true]:border"
data-scrollable="false"
role="table"
role={withTableRole ? 'table' : undefined}
>
{header ? (
<div
@@ -1,10 +1,12 @@
import assertNever from 'assert-never';
import type { DocumentBlockTable } from '@gitbook/api';
import type { DocumentBlockTable, DocumentTableViewGrid } from '@gitbook/api';
import type { BlockProps } from '../Block';
import { isBlockOffscreen } from '../utils';
import { type TableCellMergeLayout, createTableCellMergeLayout } from './cellMerges';
import { getViewGridLayout, hasVisibleHeader } from './layout';
import { NativeViewGrid } from './NativeViewGrid';
import {
type TableRecordKV,
getTableCheckboxColumns,
@@ -26,6 +28,10 @@ export interface TableViewProps<View> extends BlockProps<DocumentBlockTable> {
isOffscreen: boolean;
}
export interface TableGridViewProps extends TableViewProps<DocumentTableViewGrid> {
cellMergeLayout: TableCellMergeLayout;
}
export function Table(props: BlockProps<DocumentBlockTable>) {
const { block, ancestorBlocks, document, context, style } = props;
const isOffscreen = isBlockOffscreen({ block, ancestorBlocks, document });
@@ -45,9 +51,18 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
const searchRecords = showSearch
? records.map(([id, record]) => ({ id, ...getTableRecordSearchData(block, record) }))
: [];
const cellMergeLayout = createTableCellMergeLayout(
block,
records.map(([recordId]) => recordId)
);
return (
<TableSearchProvider records={searchRecords}>
<TableSearchProvider
records={searchRecords}
recordGroups={
block.data.view.type === 'grid' ? cellMergeLayout.recordGroups : undefined
}
>
<div className={tcls(style, 'flex flex-col gap-3')}>
{showSearch ? (
<TableSearchInput
@@ -55,7 +70,12 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
checkboxColumns={getTableCheckboxColumns(block)}
/>
) : null}
<TableView {...props} isOffscreen={isOffscreen} records={records} />
<TableView
{...props}
isOffscreen={isOffscreen}
records={records}
cellMergeLayout={cellMergeLayout}
/>
<TableSearchEmpty />
</div>
</TableSearchProvider>
@@ -68,8 +88,13 @@ export function Table(props: BlockProps<DocumentBlockTable>) {
function TableView({
isOffscreen,
records,
cellMergeLayout,
...props
}: BlockProps<DocumentBlockTable> & { isOffscreen: boolean; records: TableRecordKV[] }) {
}: BlockProps<DocumentBlockTable> & {
isOffscreen: boolean;
records: TableRecordKV[];
cellMergeLayout: TableCellMergeLayout;
}) {
const { block, context, style } = props;
switch (block.data.view.type) {
@@ -88,6 +113,7 @@ function TableView({
view: block.data.view,
isOffscreen,
records,
cellMergeLayout,
};
const { tableWidth } = getViewGridLayout({
block,
@@ -102,6 +128,10 @@ function TableView({
const withStickyFirstColumn =
context.mode !== 'print' && block.data.view.stickyFirstColumn === true;
if (cellMergeLayout.hasVerticalMerges) {
return <NativeViewGrid {...gridProps} />;
}
if (withStickyHeader || withStickyFirstColumn) {
return (
<StickyViewGrid
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import { type TableRowRange, tableRowRangesIntersect } from './TableHoverTable';
const CELL_RANGES = {
A: { rowStart: 0, rowEnd: 1 },
B: { rowStart: 0, rowEnd: 0 },
C: { rowStart: 0, rowEnd: 0 },
D: { rowStart: 1, rowEnd: 1 },
E: { rowStart: 1, rowEnd: 2 },
F: { rowStart: 2, rowEnd: 2 },
G: { rowStart: 2, rowEnd: 2 },
} satisfies Record<string, TableRowRange>;
function getHighlightedCells(hoveredCell: keyof typeof CELL_RANGES) {
const hoveredRange = CELL_RANGES[hoveredCell];
return Object.entries(CELL_RANGES)
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, hoveredRange))
.map(([cell]) => cell);
}
describe('tableRowRangesIntersect', () => {
it.each([
['A', ['A', 'B', 'C', 'D', 'E']],
['B', ['A', 'B', 'C']],
['D', ['A', 'D', 'E']],
['E', ['A', 'D', 'E', 'F', 'G']],
] as const)(
'highlights cells intersecting %s without transitive expansion',
(cell, expected) => {
expect(getHighlightedCells(cell)).toEqual([...expected]);
}
);
it('treats a horizontal merge as a single-row range', () => {
const horizontalMerge = { rowStart: 1, rowEnd: 1 };
expect(
Object.entries(CELL_RANGES)
.filter(([, cellRange]) => tableRowRangesIntersect(cellRange, horizontalMerge))
.map(([cell]) => cell)
).toEqual(['A', 'D', 'E']);
});
});
@@ -0,0 +1,93 @@
'use client';
import { type ComponentPropsWithoutRef, type MouseEvent as ReactMouseEvent, useRef } from 'react';
const TABLE_CELL_SELECTOR = 'td[data-table-row-start][data-table-row-end]';
export interface TableRowRange {
rowStart: number;
rowEnd: number;
}
export function tableRowRangesIntersect(left: TableRowRange, right: TableRowRange) {
return left.rowStart <= right.rowEnd && right.rowStart <= left.rowEnd;
}
/** Applies hover feedback to cells intersecting the hovered cell's original row span. */
export function TableHoverTable(props: ComponentPropsWithoutRef<'table'>) {
const { onMouseOver, onMouseLeave, ...rest } = props;
const hoveredCellRef = useRef<HTMLTableCellElement | null>(null);
const highlightedCellsRef = useRef<Set<HTMLTableCellElement>>(new Set());
const clearHighlightedCells = () => {
for (const cell of highlightedCellsRef.current) {
cell.removeAttribute('data-table-hovered');
}
highlightedCellsRef.current.clear();
hoveredCellRef.current = null;
};
const handleMouseOver = (event: ReactMouseEvent<HTMLTableElement>) => {
onMouseOver?.(event);
const target = event.target;
if (!(target instanceof Element)) {
clearHighlightedCells();
return;
}
const hoveredCell = target.closest<HTMLTableCellElement>(TABLE_CELL_SELECTOR);
if (!hoveredCell || !event.currentTarget.contains(hoveredCell)) {
clearHighlightedCells();
return;
}
if (hoveredCellRef.current === hoveredCell) {
return;
}
const hoveredRange = getTableCellRowRange(hoveredCell);
if (!hoveredRange) {
clearHighlightedCells();
return;
}
const highlightedCells = new Set<HTMLTableCellElement>();
for (const cell of event.currentTarget.querySelectorAll<HTMLTableCellElement>(
TABLE_CELL_SELECTOR
)) {
const cellRange = getTableCellRowRange(cell);
const highlighted =
cellRange !== null && tableRowRangesIntersect(cellRange, hoveredRange);
cell.toggleAttribute('data-table-hovered', highlighted);
if (highlighted) {
highlightedCells.add(cell);
}
}
for (const cell of highlightedCellsRef.current) {
if (!highlightedCells.has(cell)) {
cell.removeAttribute('data-table-hovered');
}
}
hoveredCellRef.current = hoveredCell;
highlightedCellsRef.current = highlightedCells;
};
const handleMouseLeave = (event: ReactMouseEvent<HTMLTableElement>) => {
onMouseLeave?.(event);
clearHighlightedCells();
};
return <table {...rest} onMouseOver={handleMouseOver} onMouseLeave={handleMouseLeave} />;
}
function getTableCellRowRange(cell: HTMLTableCellElement): TableRowRange | null {
const rowStart = Number(cell.dataset.tableRowStart);
const rowEnd = Number(cell.dataset.tableRowEnd);
if (!Number.isInteger(rowStart) || !Number.isInteger(rowEnd) || rowEnd < rowStart) {
return null;
}
return { rowStart, rowEnd };
}
@@ -5,7 +5,11 @@ import React from 'react';
import { Icon } from '@gitbook/icons';
import type { TableCheckboxColumn, TableSelectColumn } from './search';
import { type SelectedOptions, recordMatches } from './searchMatch';
import {
type SelectedOptions,
type TableSearchRecordData,
getVisibleTableRecordIds,
} from './searchMatch';
import { Button, Checkbox, DropdownMenu, DropdownMenuItem, Input } from '@/components/primitives';
import { tString, useLanguage } from '@/intl/client';
import { type ClassValue, tcls } from '@/lib/tailwind';
@@ -18,15 +22,6 @@ import { type ClassValue, tcls } from '@/lib/tailwind';
* once and exposes the set of visible ids; each row/card just looks itself up by id.
*/
/** Per-record matching data, computed on the server. */
export interface TableSearchRecordData {
/** Record key, matching the `key` passed to `<TableSearchRecord>`. */
id: string;
searchText: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
}
type TableSearchContextValue = {
query: string;
setQuery: (query: string) => void;
@@ -51,9 +46,10 @@ const TableSearchContext = React.createContext<TableSearchContextValue | null>(n
*/
export function TableSearchProvider(props: {
records?: TableSearchRecordData[];
recordGroups?: readonly (readonly string[])[];
children: React.ReactNode;
}) {
const { records = [] } = props;
const { records = [], recordGroups = [] } = props;
const [query, setQuery] = React.useState('');
const [selectedOptions, setSelectedOptions] = React.useState<SelectedOptions>(() => ({}));
const [checkedColumns, setCheckedColumns] = React.useState<ReadonlySet<string>>(
@@ -91,32 +87,18 @@ export function TableSearchProvider(props: {
});
}, []);
const hasActiveFilters =
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
// Match every record once, here, rather than in each row — rows just look themselves up by id.
const visibleIds = React.useMemo(() => {
if (!hasActiveFilters) {
return null;
}
const ids = new Set<string>();
for (const record of records) {
if (
recordMatches(
record.searchText,
record.selectValues,
record.checkboxValues,
query,
selectedOptions,
checkedColumns
)
) {
ids.add(record.id);
}
}
return ids;
}, [records, query, selectedOptions, checkedColumns, hasActiveFilters]);
const visibleIds = React.useMemo(
() =>
getVisibleTableRecordIds({
records,
recordGroups,
query,
selectedOptions,
checkedColumns,
}),
[records, recordGroups, query, selectedOptions, checkedColumns]
);
const isEmpty = visibleIds !== null && records.length > 0 && visibleIds.size === 0;
@@ -310,3 +292,13 @@ export function TableSearchRecord(props: TableSearchRecordProps) {
</div>
);
}
/** Keeps a complete native table row group visible when any of its records matches. */
export function TableSearchTableBody(
props: React.HTMLAttributes<HTMLTableSectionElement> & { recordIds: readonly string[] }
) {
const { recordIds, children, ...rest } = props;
const { visibleIds } = useTableSearch();
const matches = visibleIds === null || recordIds.some((recordId) => visibleIds.has(recordId));
return matches ? <tbody {...rest}>{children}</tbody> : null;
}
@@ -0,0 +1,63 @@
import { describe, expect, it, mock } from 'bun:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { IconsProvider } from '@gitbook/icons';
import { TranslateContext } from '@/intl/client';
import { en } from '@/intl/translations/en';
mock.module('./RecordCard', () => ({
RecordCard: () => <div data-testid="record-card" />,
}));
mock.module('./TableSearch', () => ({
TableSearchRecord: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const { ViewCards } = await import('./ViewCards');
function makeProps(wrap: boolean) {
return {
block: { data: { fullWidth: false } },
view: { type: 'cards', wrap, cardSize: 'medium' },
records: [
['record-1', {}],
['record-2', {}],
['record-3', {}],
] as any,
context: { mode: 'default' },
style: undefined,
} as any;
}
function renderWithContext(children: React.ReactNode) {
return renderToStaticMarkup(
<IconsProvider assetsURL="https://icons.example.com">
<TranslateContext.Provider value={en}>{children}</TranslateContext.Provider>
</IconsProvider>
);
}
describe('ViewCards', () => {
it('renders the carousel with a symmetric peek and conditional edge masks', () => {
const markup = renderWithContext(<ViewCards {...makeProps(false)} />);
expect(markup).toContain('-mx-12');
expect(markup).toContain('px-12');
expect(markup).toContain('scroll-px-12');
expect(markup).toContain('left-0');
expect(markup).toContain('ml-8');
expect(markup).toContain('right-0');
expect(markup).toContain('mr-8');
expect(markup).not.toContain('before:bg-linear-to-r');
expect(markup).not.toContain('after:bg-linear-to-l');
expect(markup).toContain('snap-mandatory');
});
it('renders the wrapping grid by default', () => {
const markup = renderWithContext(<ViewCards {...makeProps(true)} />);
expect(markup).toContain('inline-grid');
});
});
@@ -53,11 +53,9 @@ function CardsGrid(props: TableViewProps<DocumentTableViewCards>) {
* The carousel layout: cards lay out in a single horizontally-scrolling row that
* snaps to the leftmost card. Reuses ScrollContainer for the scroll buttons.
*
* Rather than fading the edges, the row breaks out of the content column so it can
* scroll to the page edges. Negative margins on the outer wrapper pull it out; matching
* padding + scroll-padding on the scroller keep the first/last cards aligned with the
* body text at rest and snapping to that edge, while cards bleed to the edge mid-scroll.
* See `bleedVars` below for how far each side reaches.
* The row extends by a small, symmetric peek on each side. The matching padding keeps
* the first and last cards aligned with the content column while the edge masks fade only
* the cards in that peek area.
*/
function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
const { view, records } = props;
@@ -69,65 +67,10 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
? 'w-[90%] @sm:w-[calc(45%-0.5rem)] @5xl:w-[calc(50%-0.5rem)]'
: 'w-[90%] @sm:w-[calc(45%-0.5rem)] @xl:w-[calc(30%-0.66rem)] @5xl:w-[calc(33.33%-0.66rem)]';
// Break the row out of the content column so it bleeds to the page edges instead of
// fading. `--cards-bleed-l/r` are the distances to pull each side out by; they drive the
// negative margins (on the wrapper) and the matching padding + scroll-padding (on the
// scroller), so the first/last cards stay aligned with the body text at rest while cards
// bleed to the edge mid-scroll. Kept as literals here since it's a single block.
//
// The bleed only makes sense on the default layout, where the 48rem column leaves wide
// empty margins to reclaim. The wide layout (max-w-6xl) already fills the usable width,
// so from `lg` we suppress the bleed entirely — otherwise the page gutter would push the
// row past where every other block ends, jutting into the window frame.
//
// On the default layout:
// - Left is capped at the page gutter (1/1.5/2rem) so it never slides under the TOC.
// - Right reaches the viewport edge from `lg`. The 48rem column is centred in the space
// beside the TOC, so the gap to the viewport edge is `50vw` minus half the column
// (24rem), minus half the TOC (10.5rem of the 21rem `w-72`+`mr-12`) when one is shown.
// `html` clips horizontal overflow, so a small overshoot is harmless.
// - Right collapses to 0 once an outline occupies that column (shown from `xl`), so
// cards never slide under it.
const bleedVars = tcls(
'[--cards-bleed-l:1rem]',
'sm:[--cards-bleed-l:1.5rem]',
'md:[--cards-bleed-l:2rem]',
'layout-default:md:max-lg:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
'lg:[--cards-bleed-l:max(calc(50vw-34rem),3rem)]',
'xl:[--cards-bleed-l:3rem]',
'[--cards-bleed-r:1rem]',
'sm:[--cards-bleed-r:1.5rem]',
'md:[--cards-bleed-r:2rem]',
'layout-default:md:max-lg:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
'layout-default:lg:[--cards-bleed-r:max(calc(50vw-35rem),3rem)]',
'layout-default:xl:[--cards-bleed-r:3rem]',
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-l:max(calc(50vw-24.5rem),2rem)]',
'hover:layout-default:no-sidebar:lg:max-xl:[--cards-bleed-r:max(calc(50vw-24.5rem),2rem)]',
// Default centered
'hover:layout-default:no-sidebar:xl:[--cards-bleed-l:max(calc(50vw-22.5rem),2rem)]',
'hover:layout-default:xl:[--cards-bleed-r:max(calc(50vw-26.5rem),19rem)]',
// Full width, no outline
'hover:layout-wide:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-43.5rem),0rem)]',
// Full width centered
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-l:max(calc(50vw-36.5rem),0rem)]',
'layout-wide:no-sidebar:page-no-outline:2xl:[--cards-bleed-r:max(calc(50vw-36.5rem),0rem)]'
);
return (
<ScrollContainer
orientation="horizontal"
className={tcls(
bleedVars,
'ml-[calc(var(--cards-bleed-l)*-1)]',
'mr-[calc(var(--cards-bleed-r)*-1)]',
'xl:transition-[margin]',
'hover:z-11'
)}
className={tcls('-mx-12', 'hover:z-11')}
// `py-1` keeps the card ring/shadow from being clipped by the scroll overflow;
// `snap-mandatory` + the scroll-padding snap each card to the content edge.
contentClassName={tcls(
@@ -136,21 +79,19 @@ function CardsCarousel(props: TableViewProps<DocumentTableViewCards>) {
'-mt-px',
'pb-6',
'-mb-6',
'pl-[var(--cards-bleed-l)]',
'pr-[var(--cards-bleed-r)]',
'scroll-pl-[var(--cards-bleed-l)]',
'scroll-pr-[var(--cards-bleed-r)]',
'px-12',
'scroll-px-12',
'snap-x',
'snap-mandatory',
'xl:transition-[padding]'
'snap-mandatory'
)}
scrollByVisibleItems
leading={{
fade: true,
button: { size: 'small', className: 'ml-[calc(var(--cards-bleed-l)-1rem)]' },
button: { size: 'small', className: 'ml-8' },
}}
trailing={{
fade: true,
button: { size: 'small', className: 'mr-[calc(var(--cards-bleed-r)-1rem)]' },
button: { size: 'small', className: 'mr-8' },
}}
>
{records.map((record) => {
@@ -1,17 +1,15 @@
import type { DocumentTableViewGrid } from '@gitbook/api';
import { getColumnWidth, getViewGridLayout } from './layout';
import { RecordRow } from './RecordRow';
import type { TableViewProps } from './Table';
import type { TableGridViewProps } from './Table';
import { getColumnAlignment } from './utils';
import { tcls } from '@/lib/tailwind';
interface ViewGridHeaderProps extends TableViewProps<DocumentTableViewGrid> {
interface ViewGridHeaderProps extends TableGridViewProps {
className?: string;
tableClassName?: string;
}
interface ViewGridProps extends TableViewProps<DocumentTableViewGrid> {
interface ViewGridProps extends TableGridViewProps {
tableClassName?: string;
}
@@ -0,0 +1,217 @@
import { describe, expect, it } from 'bun:test';
import type { DocumentBlockTable, DocumentTableDefinition } from '@gitbook/api';
import { createTableCellMergeLayout, getMergedCellWidth, getTableCellMerge } from './cellMerges';
const RECORD_ORDER = ['first', 'second', 'third'];
const COLUMN_ORDER = ['a', 'b', 'c'];
function createBlock(
cellMerges?: unknown[],
options: {
columns?: string[];
definitions?: Record<string, DocumentTableDefinition>;
recordOrder?: string[];
viewType?: 'grid' | 'cards';
} = {}
) {
const columns = options.columns ?? COLUMN_ORDER;
const recordOrder = options.recordOrder ?? RECORD_ORDER;
const definitions =
options.definitions ??
Object.fromEntries(columns.map((column) => [column, createDefinition('text', column)]));
return {
data: {
view:
options.viewType === 'cards'
? { type: 'cards', columns }
: { type: 'grid', columns },
records: Object.fromEntries(
recordOrder.map((recordId, index) => [
recordId,
{ orderIndex: `${index}`, values: {} },
])
),
definition: definitions,
...(cellMerges ? { cellMerges } : {}),
},
} as unknown as DocumentBlockTable;
}
function createDefinition(type: DocumentTableDefinition['type'], id: string) {
const base = { id, title: id };
return type === 'text'
? ({ ...base, type, textAlignment: 'left' } as DocumentTableDefinition)
: ({ ...base, type } as DocumentTableDefinition);
}
describe('createTableCellMergeLayout', () => {
it('classifies horizontal and vertical Text and Number merges', () => {
for (const type of ['text', 'number'] as const) {
const layout = createTableCellMergeLayout(
createBlock([horizontalMerge('first', 'a', 2), verticalMerge('second', 'c', 2)], {
definitions: Object.fromEntries(
COLUMN_ORDER.map((column) => [column, createDefinition(type, column)])
),
}),
RECORD_ORDER
);
expect(getTableCellMerge(layout, 'first', 'a')).toMatchObject({
isAnchor: true,
merge: { rowSpan: 1, colSpan: 2, columns: ['a', 'b'] },
});
expect(getTableCellMerge(layout, 'first', 'b')).toMatchObject({ isAnchor: false });
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
isAnchor: true,
merge: { rowSpan: 2, colSpan: 1, records: ['second', 'third'] },
});
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
expect(layout.hasVerticalMerges).toBe(true);
}
});
it('treats missing metadata and Cards as unmerged layouts', () => {
const missing = createTableCellMergeLayout(createBlock(), RECORD_ORDER);
const cards = createTableCellMergeLayout(
createBlock([horizontalMerge('first', 'a', 2)], {
viewType: 'cards',
}),
RECORD_ORDER
);
expect(missing.cells.size).toBe(0);
expect(missing.recordGroups).toEqual([['first'], ['second'], ['third']]);
expect(missing.hasVerticalMerges).toBe(false);
expect(cards.cells.size).toBe(0);
expect(cards.recordGroups).toEqual([]);
});
it.each([
['invalid shape', { anchor: 'first', rowSpan: 1, colSpan: 2 }],
['single cell', { anchor: { record: 'first', column: 'a' }, rowSpan: 1, colSpan: 1 }],
['unknown record', horizontalMerge('missing', 'a', 2)],
['hidden column', horizontalMerge('first', 'hidden', 2)],
['out-of-bounds span', horizontalMerge('first', 'c', 2)],
['rectangle', { anchor: { record: 'first', column: 'a' }, rowSpan: 2, colSpan: 2 }],
])('ignores malformed metadata: %s', (_name, merge) => {
const block = createBlock([merge], {
definitions: {
a: createDefinition('text', 'a'),
b: createDefinition('text', 'b'),
c: createDefinition('text', 'c'),
hidden: createDefinition('text', 'hidden'),
},
});
const layout = createTableCellMergeLayout(block, RECORD_ORDER);
expect(layout.cells.size).toBe(0);
expect(layout.hasVerticalMerges).toBe(false);
});
it('drops every conflicting merge regardless of order while preserving unrelated merges', () => {
const horizontal = horizontalMerge('first', 'a', 2);
const vertical = verticalMerge('first', 'b', 2);
const duplicate = horizontalMerge('first', 'a', 2);
const unrelated = verticalMerge('second', 'c', 2);
for (const cellMerges of [
[horizontal, vertical, duplicate, unrelated],
[vertical, duplicate, horizontal, unrelated],
]) {
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
expect(getTableCellMerge(layout, 'first', 'a')).toBeUndefined();
expect(getTableCellMerge(layout, 'first', 'b')).toBeUndefined();
expect(getTableCellMerge(layout, 'second', 'b')).toBeUndefined();
expect(getTableCellMerge(layout, 'second', 'c')).toMatchObject({
isAnchor: true,
merge: { records: ['second', 'third'], columns: ['c'] },
});
expect(getTableCellMerge(layout, 'third', 'c')).toMatchObject({ isAnchor: false });
expect(layout.recordGroups).toEqual([['first'], ['second', 'third']]);
}
});
it('partitions disjoint vertical merges and singleton records in record order', () => {
const recordOrder = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth'];
const layout = createTableCellMergeLayout(
createBlock([verticalMerge('fourth', 'b', 2), verticalMerge('first', 'a', 2)], {
recordOrder,
}),
recordOrder
);
expect(layout.recordGroups).toEqual([
['first', 'second'],
['third'],
['fourth', 'fifth'],
['sixth'],
]);
});
it('combines transitively connected vertical merges regardless of metadata order', () => {
for (const cellMerges of [
[verticalMerge('first', 'a', 2), verticalMerge('second', 'b', 2)],
[verticalMerge('second', 'b', 2), verticalMerge('first', 'a', 2)],
]) {
const layout = createTableCellMergeLayout(createBlock(cellMerges), RECORD_ORDER);
expect(layout.recordGroups).toEqual([['first', 'second', 'third']]);
}
});
it('ignores merges for unsupported or mixed field types', () => {
const unsupportedDefinitions = {
a: createDefinition('checkbox', 'a'),
b: createDefinition('checkbox', 'b'),
};
const unsupportedHorizontal = createTableCellMergeLayout(
createBlock([horizontalMerge('first', 'a', 2)], {
columns: ['a', 'b'],
definitions: unsupportedDefinitions,
}),
RECORD_ORDER
);
const unsupportedVertical = createTableCellMergeLayout(
createBlock([verticalMerge('first', 'a', 2)], {
columns: ['a', 'b'],
definitions: unsupportedDefinitions,
}),
RECORD_ORDER
);
const mixed = createTableCellMergeLayout(
createBlock([horizontalMerge('first', 'a', 2)], {
columns: ['a', 'b'],
definitions: {
a: createDefinition('text', 'a'),
b: createDefinition('number', 'b'),
},
}),
RECORD_ORDER
);
expect(unsupportedHorizontal.cells.size).toBe(0);
expect(unsupportedVertical.cells.size).toBe(0);
expect(unsupportedVertical.hasVerticalMerges).toBe(false);
expect(mixed.cells.size).toBe(0);
});
});
function horizontalMerge(record: string, column: string, colSpan: number) {
return { anchor: { record, column }, rowSpan: 1, colSpan };
}
function verticalMerge(record: string, column: string, rowSpan: number) {
return { anchor: { record, column }, rowSpan, colSpan: 1 };
}
describe('getMergedCellWidth', () => {
it('adds fixed and automatic column widths in CSS', () => {
expect(getMergedCellWidth(['120px', 'clamp(100px, calc(100% / 2), 100%)'])).toBe(
'calc(120px + clamp(100px, calc(100% / 2), 100%))'
);
});
});
@@ -0,0 +1,232 @@
import type { DocumentBlockTable } from '@gitbook/api';
/**
* Temporary compatibility type until the published `@gitbook/api` includes `cellMerges`.
*/
export interface TableCellMerge {
anchor: {
record: string;
column: string;
};
rowSpan: number;
colSpan: number;
}
type TableDataWithCellMerges = DocumentBlockTable['data'] & {
cellMerges?: TableCellMerge[];
};
export interface ResolvedTableCellMerge {
records: readonly string[];
columns: readonly string[];
rowSpan: number;
colSpan: number;
}
export interface TableCellMergeSlot {
merge: ResolvedTableCellMerge;
isAnchor: boolean;
}
export interface TableCellMergeLayout {
cells: ReadonlyMap<string, ReadonlyMap<string, TableCellMergeSlot>>;
recordGroups: readonly (readonly string[])[];
hasVerticalMerges: boolean;
}
/**
* Resolve valid merge metadata once for a rendered grid.
*
* Invalid external metadata is ignored before any cell can be classified as covered, ensuring a
* malformed merge always degrades to ordinary cells instead of hiding content.
*/
export function createTableCellMergeLayout(
block: DocumentBlockTable,
recordOrder: readonly string[]
): TableCellMergeLayout {
if (block.data.view.type !== 'grid') {
return {
cells: new Map(),
recordGroups: [],
hasVerticalMerges: false,
};
}
const columnOrder = block.data.view.columns;
const candidates: { merge: ResolvedTableCellMerge; cellKeys: string[] }[] = [];
const cells = new Map<string, Map<string, TableCellMergeSlot>>();
const verticalRecordGroups: string[][] = [];
for (const candidate of getRawTableCellMerges(block)) {
const merge = resolveTableCellMerge(candidate, block, recordOrder, columnOrder);
if (!merge) continue;
const cellKeys = merge.records.flatMap((recordId) =>
merge.columns.map((columnId) => getCellKey(recordId, columnId))
);
candidates.push({ merge, cellKeys });
}
const cellOwnerCounts = new Map<string, number>();
for (const candidate of candidates) {
for (const cellKey of candidate.cellKeys) {
cellOwnerCounts.set(cellKey, (cellOwnerCounts.get(cellKey) ?? 0) + 1);
}
}
for (const candidate of candidates) {
if (candidate.cellKeys.some((cellKey) => cellOwnerCounts.get(cellKey) !== 1)) continue;
const { merge } = candidate;
for (const recordId of merge.records) {
let row = cells.get(recordId);
if (!row) {
row = new Map();
cells.set(recordId, row);
}
for (const columnId of merge.columns) {
row.set(columnId, {
merge,
isAnchor: recordId === merge.records[0] && columnId === merge.columns[0],
});
}
}
if (merge.rowSpan > 1) {
verticalRecordGroups.push([...merge.records]);
}
}
return {
cells,
recordGroups: groupConnectedRecords(recordOrder, verticalRecordGroups),
hasVerticalMerges: verticalRecordGroups.length > 0,
};
}
/** Return the resolved merge classification for a logical table cell. */
export function getTableCellMerge(
layout: TableCellMergeLayout,
recordId: string,
columnId: string
): TableCellMergeSlot | undefined {
return layout.cells.get(recordId)?.get(columnId);
}
/** Combine the independent column widths used by a horizontal merged cell. */
export function getMergedCellWidth(widths: string[]): string {
return widths.length === 1 && widths[0] ? widths[0] : `calc(${widths.join(' + ')})`;
}
function getRawTableCellMerges(block: DocumentBlockTable): unknown[] {
const data = block.data as TableDataWithCellMerges;
return Array.isArray(data.cellMerges) ? data.cellMerges : [];
}
function resolveTableCellMerge(
candidate: unknown,
block: DocumentBlockTable,
recordOrder: readonly string[],
columnOrder: readonly string[]
): ResolvedTableCellMerge | null {
if (!candidate || typeof candidate !== 'object') {
return null;
}
const merge = candidate as {
anchor?: { record?: unknown; column?: unknown };
rowSpan?: unknown;
colSpan?: unknown;
};
if (
typeof merge.anchor?.record !== 'string' ||
typeof merge.anchor.column !== 'string' ||
typeof merge.rowSpan !== 'number' ||
!Number.isInteger(merge.rowSpan) ||
merge.rowSpan < 1 ||
typeof merge.colSpan !== 'number' ||
!Number.isInteger(merge.colSpan) ||
merge.colSpan < 1 ||
!(
(merge.rowSpan === 1 && merge.colSpan >= 2) ||
(merge.rowSpan >= 2 && merge.colSpan === 1)
)
) {
return null;
}
const recordStart = recordOrder.indexOf(merge.anchor.record);
const columnStart = columnOrder.indexOf(merge.anchor.column);
if (recordStart < 0 || columnStart < 0) {
return null;
}
const records = recordOrder.slice(recordStart, recordStart + merge.rowSpan);
const columns = columnOrder.slice(columnStart, columnStart + merge.colSpan);
const recordsValid =
records.length === merge.rowSpan &&
records.every((recordId) => Boolean(block.data.records[recordId]));
const definitions = columns.map((columnId) => block.data.definition[columnId]);
const columnsValid =
columns.length === merge.colSpan &&
definitions.every(
(definition) => definition?.type === 'text' || definition?.type === 'number'
) &&
definitions.every((definition) => definition?.type === definitions[0]?.type);
if (!recordsValid || !columnsValid) {
return null;
}
return {
records: [...records],
columns: [...columns],
rowSpan: merge.rowSpan,
colSpan: merge.colSpan,
};
}
function getCellKey(recordId: string, columnId: string): string {
return `${recordId.length}:${recordId}${columnId}`;
}
/** Partition records into ordered groups connected by one or more vertical merges. */
function groupConnectedRecords(
recordOrder: readonly string[],
connectedRecordGroups: readonly (readonly string[])[]
): string[][] {
const parents = new Map(recordOrder.map((recordId) => [recordId, recordId]));
const findRoot = (recordId: string): string => {
const parent = parents.get(recordId);
if (!parent || parent === recordId) {
return recordId;
}
const root = findRoot(parent);
parents.set(recordId, root);
return root;
};
for (const group of connectedRecordGroups) {
const firstRecordId = group[0];
if (!firstRecordId) continue;
for (const recordId of group.slice(1)) {
parents.set(findRoot(recordId), findRoot(firstRecordId));
}
}
const groupedRecords = new Map<string, string[]>();
for (const recordId of recordOrder) {
const root = findRoot(recordId);
const group = groupedRecords.get(root);
if (group) {
group.push(recordId);
} else {
groupedRecords.set(root, [recordId]);
}
}
return [...groupedRecords.values()];
}
@@ -1,10 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { type SelectedOptions, matchesText, recordMatches } from './searchMatch';
import {
type SelectedOptions,
type TableSearchRecordData,
getVisibleTableRecordIds,
matchesText,
recordMatches,
} from './searchMatch';
const NO_OPTIONS: SelectedOptions = {};
const NO_CHECKBOXES: ReadonlySet<string> = new Set();
function visibleIds(
records: TableSearchRecordData[],
filters: {
query?: string;
selectedOptions?: SelectedOptions;
checkedColumns?: ReadonlySet<string>;
},
recordGroups: string[][] = []
) {
return getVisibleTableRecordIds({
records,
recordGroups,
query: filters.query ?? '',
selectedOptions: filters.selectedOptions ?? NO_OPTIONS,
checkedColumns: filters.checkedColumns ?? NO_CHECKBOXES,
});
}
function match(
record: {
searchText?: string;
@@ -149,3 +173,75 @@ describe('recordMatches', () => {
expect(match({ ...record, checkboxValues: { featured: false } }, filters)).toBe(false);
});
});
describe('getVisibleTableRecordIds', () => {
const records: TableSearchRecordData[] = [
{
id: 'first',
searchText: 'Anchor value',
selectValues: { status: ['active'] },
checkboxValues: { featured: false },
},
{
id: 'second',
searchText: 'Covered value',
selectValues: { status: ['archived'] },
checkboxValues: { featured: true },
},
{
id: 'third',
searchText: 'Connected value',
selectValues: { status: ['pending'] },
checkboxValues: { featured: false },
},
{
id: 'unrelated',
searchText: 'Unrelated value',
selectValues: { status: ['archived'] },
checkboxValues: { featured: false },
},
];
const verticalGroup = [['first', 'second']];
it('returns null when no filter is active', () => {
expect(visibleIds(records, {}, verticalGroup)).toBeNull();
});
it('keeps a complete vertical group when text matches its anchor', () => {
expect(visibleIds(records, { query: 'anchor' }, verticalGroup)).toEqual(
new Set(['first', 'second'])
);
});
it('keeps a complete vertical group when select or checkbox filters match a grouped row', () => {
expect(
visibleIds(records, { selectedOptions: { status: new Set(['active']) } }, verticalGroup)
).toEqual(new Set(['first', 'second']));
expect(
visibleIds(records, { checkedColumns: new Set(['featured']) }, verticalGroup)
).toEqual(new Set(['second', 'first']));
});
it('excludes completely unmatched and disconnected groups', () => {
expect(visibleIds(records, { query: 'unrelated' }, verticalGroup)).toEqual(
new Set(['unrelated'])
);
});
it('expands transitively connected groups in reversed metadata order without mutation', () => {
const groups = [
['second', 'third'],
['first', 'second'],
];
const originalGroups = groups.map((group) => [...group]);
expect(visibleIds(records, { query: 'anchor' }, groups)).toEqual(
new Set(['first', 'second', 'third'])
);
expect(groups).toEqual(originalGroups);
});
it('leaves cards independently filtered when no merge groups are provided', () => {
expect(visibleIds(records, { query: 'anchor' })).toEqual(new Set(['first']));
});
});
@@ -8,6 +8,72 @@
/** Selected option values per select column, keyed by column id. */
export type SelectedOptions = Readonly<Record<string, ReadonlySet<string>>>;
/** Per-record data used by the client-side table search. */
export interface TableSearchRecordData {
id: string;
searchText: string;
selectValues?: Record<string, string[]>;
checkboxValues?: Record<string, boolean>;
}
/**
* Match records and expand matching vertical merge groups to a fixed point.
*/
export function getVisibleTableRecordIds({
records,
recordGroups = [],
query,
selectedOptions,
checkedColumns,
}: {
records: readonly TableSearchRecordData[];
recordGroups?: readonly (readonly string[])[];
query: string;
selectedOptions: SelectedOptions;
checkedColumns: ReadonlySet<string>;
}): ReadonlySet<string> | null {
const hasActiveFilters =
query.trim() !== '' || Object.keys(selectedOptions).length > 0 || checkedColumns.size > 0;
if (!hasActiveFilters) {
return null;
}
const visibleIds = new Set<string>();
for (const record of records) {
if (
recordMatches(
record.searchText,
record.selectValues,
record.checkboxValues,
query,
selectedOptions,
checkedColumns
)
) {
visibleIds.add(record.id);
}
}
let expanded = true;
while (expanded) {
expanded = false;
for (const group of recordGroups) {
if (!group.some((id) => visibleIds.has(id))) {
continue;
}
for (const id of group) {
if (!visibleIds.has(id)) {
visibleIds.add(id);
expanded = true;
}
}
}
}
return visibleIds;
}
/**
* Whether a record passes the current filters.
*
@@ -5,7 +5,7 @@ import QuickLRU from 'quick-lru';
import React from 'react';
import { createStore, useStore } from 'zustand';
import type { GitSyncState } from '@gitbook/api';
import { type GitSyncState, SiteInsightsMarkdownSource } from '@gitbook/api';
import { Icon, type IconName, IconStyle } from '@gitbook/icons';
import { useAIChatController, useAIChatState } from '@/components/AI';
@@ -98,9 +98,14 @@ const createCopiedStateStore = () => {
set({ copied: true });
timeoutRef = setTimeout(() => {
set({ copied: false });
onSuccess?.();
timeoutRef = null;
// Delay resetting the label past the dropdown's closing animation (`scaleOut`,
// 200ms) so the "Copied" label doesn't flip back while still visible mid-fade.
timeoutRef = setTimeout(() => {
set({ copied: false });
timeoutRef = null;
}, 200);
}, 1500);
},
}));
@@ -120,6 +125,10 @@ function useCopiedStore(stateKey: string) {
return useStore(getOrCreateCopiedStoreByKey(stateKey));
}
function getReaderMarkdownURL(markdownPageURL: string) {
return `${markdownPageURL}?displayAgentInstructions=false&markdownSource=${SiteInsightsMarkdownSource.PageAction}`;
}
/**
* Cache for the markdown version of the page.
*/
@@ -144,7 +153,7 @@ export function ActionCopyMarkdown(props: {
const fetchMarkdown = async () => {
setLoading(true);
const humanURL = `${markdownPageURL}?displayAgentInstructions=false`;
const humanURL = getReaderMarkdownURL(markdownPageURL);
const result = await fetch(humanURL).then((res) => res.text());
markdownCache.set(markdownPageURL, result);
@@ -153,13 +162,7 @@ export function ActionCopyMarkdown(props: {
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.
if (!isDefaultAction) {
e.preventDefault();
}
const onClick = async () => {
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.
@@ -179,6 +182,7 @@ export function ActionCopyMarkdown(props: {
description={tString(language, 'copy_page_markdown')}
onClick={onClick}
loading={loading}
closeOnClick={false}
/>
);
}
@@ -196,7 +200,7 @@ export function ActionViewAsMarkdown(props: { markdownPageURL: string; type: Pag
icon="markdown"
label={tString(language, 'view_page_markdown')}
description={tString(language, 'view_page_plaintext')}
href={`${markdownPageURL}?displayAgentInstructions=false`}
href={getReaderMarkdownURL(markdownPageURL)}
/>
);
}
@@ -421,9 +425,7 @@ export function CopyToClipboard(props: {
icon={copied ? 'check' : icon}
label={copied ? tString(language, 'code_copied') : label}
description={description}
onClick={(e) => {
e.preventDefault();
onClick={() => {
copy(data, {
onSuccess: () => {
if (type === 'dropdown-menu-item') {
@@ -432,6 +434,7 @@ export function CopyToClipboard(props: {
},
});
}}
closeOnClick={false}
/>
);
}
@@ -453,6 +456,7 @@ function PageActionWrapper(props: {
target?: React.HTMLAttributeAnchorTarget;
disabled?: boolean;
loading?: boolean;
closeOnClick?: boolean;
}) {
const {
type,
@@ -465,6 +469,7 @@ function PageActionWrapper(props: {
description,
disabled,
loading,
closeOnClick,
} = props;
if (type === 'button') {
@@ -498,6 +503,7 @@ function PageActionWrapper(props: {
target={target}
onClick={onClick}
disabled={disabled || loading}
closeOnClick={closeOnClick}
>
<div className="flex size-5 items-center justify-center text-tint">
{loading ? (
@@ -564,6 +564,19 @@ html.dark .highlight-line.diff-deleted .highlight-line-content::before {
@apply rounded-none!;
}
@layer components {
.paragraph {
/* Inline action buttons grow to the available width, which needs a flex parent. */
&:has(.button, input) {
@apply flex flex-wrap items-center gap-2;
}
&[data-cover-aware-text]:not(:has(.button, input)) {
@apply text-contrast-cover;
}
}
}
/* Zoomable images */
html:has(.zoom-modal) {
/* stylelint-disable-next-line plugin/no-unsupported-browser-features -- single-value overflow is universal; doiuse flags the whole css-overflow feature */
@@ -198,14 +198,23 @@ export const SearchResults = React.forwardRef(function SearchResults(
const itemKey = getResultKey(item);
const shouldAnimateItem =
shouldAnimateResults || !seenResultKeys.current.has(itemKey);
const handleResultSelect = () => {
const handleResultSelect = (
event: React.MouseEvent<HTMLAnchorElement>
) => {
const isPageResult =
item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record';
if (
query &&
siteSpaceId &&
(item.type === 'local-page' ||
item.type === 'page' ||
item.type === 'record')
isPageResult &&
!event.currentTarget.hash &&
event.currentTarget.pathname === window.location.pathname
) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
if (query && siteSpaceId && isPageResult) {
addRecentSearchQuery(siteSpaceId, query, 'search');
}
@@ -0,0 +1,99 @@
import { afterAll, beforeAll, describe, expect, it } from 'bun:test';
import { scrollByItemsInContainer } from './ScrollContainer';
type MockRect = {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
};
class MockElement {
constructor(private readonly rect: MockRect) {}
getBoundingClientRect() {
return this.rect;
}
}
const originalHTMLElement = globalThis.HTMLElement;
beforeAll(() => {
Object.defineProperty(globalThis, 'HTMLElement', {
configurable: true,
value: MockElement,
});
});
afterAll(() => {
Object.defineProperty(globalThis, 'HTMLElement', {
configurable: true,
value: originalHTMLElement,
});
});
function rect(left: number, right: number): MockRect {
return { left, right, top: 0, bottom: 100, width: right - left, height: 100 };
}
function makeContainer(
childRects: MockRect[],
options: { scrollLeft?: number; scrollWidth?: number } = {}
) {
const scrollCalls: Record<string, unknown>[] = [];
const container = Object.assign(new MockElement(rect(0, 300)), {
children: childRects.map((childRect) => new MockElement(childRect)),
clientHeight: 100,
clientWidth: 300,
scrollHeight: 100,
scrollLeft: options.scrollLeft ?? 0,
scrollTop: 0,
scrollWidth: options.scrollWidth ?? 1000,
scrollTo: (options: Record<string, unknown>) => scrollCalls.push(options),
});
return { container: container as unknown as HTMLElement, scrollCalls };
}
describe('scrollByItemsInContainer', () => {
it('advances by fully visible items and excludes a partial preview', () => {
const { container, scrollCalls } = makeContainer([
rect(0, 100),
rect(110, 210),
rect(220, 320),
rect(330, 430),
]);
scrollByItemsInContainer(container, 'horizontal', 'forward');
expect(scrollCalls).toEqual([{ top: undefined, left: 220, behavior: 'smooth' }]);
});
it('moves backward by the visible page size', () => {
const { container, scrollCalls } = makeContainer(
[rect(-220, -120), rect(-110, -10), rect(0, 100), rect(110, 210), rect(220, 320)],
{ scrollLeft: 220 }
);
scrollByItemsInContainer(container, 'horizontal', 'backward');
expect(scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
});
it('clamps at the first and last scroll positions', () => {
const firstPage = makeContainer([rect(0, 100), rect(110, 210), rect(220, 320)]);
scrollByItemsInContainer(firstPage.container, 'horizontal', 'backward');
const lastPage = makeContainer(
[rect(-240, -140), rect(-130, -30), rect(-20, 80), rect(90, 190), rect(200, 300)],
{ scrollLeft: 240, scrollWidth: 540 }
);
scrollByItemsInContainer(lastPage.container, 'horizontal', 'forward');
expect(firstPage.scrollCalls).toEqual([{ top: undefined, left: 0, behavior: 'smooth' }]);
expect(lastPage.scrollCalls).toEqual([{ top: undefined, left: 240, behavior: 'smooth' }]);
});
});
@@ -41,6 +41,9 @@ export type ScrollContainerProps = {
/** The ID or ref of the active item to scroll to. */
active?: string | React.RefObject<HTMLElement | null>;
/** Scroll by one page of fully visible direct children instead of one viewport. */
scrollByVisibleItems?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
export function ScrollContainer(props: ScrollContainerProps) {
@@ -50,6 +53,7 @@ export function ScrollContainer(props: ScrollContainerProps) {
contentClassName,
orientation,
active,
scrollByVisibleItems = false,
leading = { fade: true, button: true },
trailing = { fade: true, button: true },
...rest
@@ -85,6 +89,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
if (scrollByVisibleItems) {
scrollByItemsInContainer(container, orientation, 'forward');
return;
}
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition + container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition + container.clientWidth : undefined,
@@ -98,6 +107,11 @@ export function ScrollContainer(props: ScrollContainerProps) {
return;
}
if (scrollByVisibleItems) {
scrollByItemsInContainer(container, orientation, 'backward');
return;
}
container.scrollTo({
top: orientation === 'vertical' ? scrollPosition - container.clientHeight : undefined,
left: orientation === 'horizontal' ? scrollPosition - container.clientWidth : undefined,
@@ -191,6 +205,133 @@ export function ScrollContainer(props: ScrollContainerProps) {
);
}
const FULLY_VISIBLE_EDGE_TOLERANCE_PX = 1;
/**
* Scroll a direct-child track by the number of items currently visible in the snapport.
* Scroll padding is excluded from the measurement because it is the carousel's peek area.
*/
export function scrollByItemsInContainer(
container: HTMLElement,
orientation: 'horizontal' | 'vertical',
direction: 'forward' | 'backward'
) {
const children = Array.from(container.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement
);
const bounds = getScrollBounds(container, orientation);
const items = children
.map((element, index) => ({ element, index, rect: element.getBoundingClientRect() }))
.filter(({ rect }) => {
const size = orientation === 'horizontal' ? rect.width : rect.height;
return size > 0;
})
.map((item, index) => ({ ...item, index }));
const visibleItems = items.filter(({ rect }) => {
const start = orientation === 'horizontal' ? rect.left : rect.top;
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
return (
start >= bounds.start - FULLY_VISIBLE_EDGE_TOLERANCE_PX &&
end <= bounds.end + FULLY_VISIBLE_EDGE_TOLERANCE_PX
);
});
// A track narrower than its viewport, or one whose children have not laid out yet, should
// retain the regular viewport behavior rather than getting stuck at its current position.
if (visibleItems.length === 0) {
scrollByViewport(container, orientation, direction);
return;
}
const pageSize = visibleItems.length;
const firstVisibleItem = visibleItems[0];
const lastVisibleItem = visibleItems[visibleItems.length - 1];
if (!firstVisibleItem || !lastVisibleItem) {
scrollByViewport(container, orientation, direction);
return;
}
const targetIndex =
direction === 'forward' ? lastVisibleItem.index + 1 : firstVisibleItem.index - pageSize;
const maxScroll = getMaxScroll(container, orientation);
if (targetIndex < 0) {
scrollToPosition(container, orientation, 0);
return;
}
const target = items.find((item) => item.index === targetIndex);
if (!target) {
scrollToPosition(container, orientation, maxScroll);
return;
}
const targetStart = orientation === 'horizontal' ? target.rect.left : target.rect.top;
const targetPosition =
(orientation === 'horizontal' ? container.scrollLeft : container.scrollTop) +
targetStart -
bounds.start;
scrollToPosition(container, orientation, Math.min(Math.max(targetPosition, 0), maxScroll));
}
function getScrollBounds(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
const rect = container.getBoundingClientRect();
const computedStyle = typeof window !== 'undefined' ? window.getComputedStyle(container) : null;
const leadingPadding = Number.parseFloat(
computedStyle?.[orientation === 'horizontal' ? 'scrollPaddingLeft' : 'scrollPaddingTop'] ??
''
);
const trailingPadding = Number.parseFloat(
computedStyle?.[
orientation === 'horizontal' ? 'scrollPaddingRight' : 'scrollPaddingBottom'
] ?? ''
);
const start = orientation === 'horizontal' ? rect.left : rect.top;
const end = orientation === 'horizontal' ? rect.right : rect.bottom;
return {
start: start + (Number.isFinite(leadingPadding) ? leadingPadding : 0),
end: end - (Number.isFinite(trailingPadding) ? trailingPadding : 0),
};
}
function getMaxScroll(container: HTMLElement, orientation: 'horizontal' | 'vertical') {
return Math.max(
orientation === 'horizontal'
? container.scrollWidth - container.clientWidth
: container.scrollHeight - container.clientHeight,
0
);
}
function scrollToPosition(
container: HTMLElement,
orientation: 'horizontal' | 'vertical',
position: number
) {
container.scrollTo({
top: orientation === 'vertical' ? position : undefined,
left: orientation === 'horizontal' ? position : undefined,
behavior: 'smooth',
});
}
function scrollByViewport(
container: HTMLElement,
orientation: 'horizontal' | 'vertical',
direction: 'forward' | 'backward'
) {
const position = orientation === 'horizontal' ? container.scrollLeft : container.scrollTop;
const distance = orientation === 'horizontal' ? container.clientWidth : container.clientHeight;
const maxScroll = getMaxScroll(container, orientation);
const target = Math.min(
Math.max(position + (direction === 'forward' ? distance : -distance), 0),
maxScroll
);
scrollToPosition(container, orientation, target);
}
/**
* Scroll to an element in a container.
*/
+246
View File
@@ -491,4 +491,250 @@ describe('resolveContentRef for direct space links', () => {
expect(result?.href).toBe(guideSiteSpace.urls.published!);
expect(result?.active).toBe(false);
});
function buildDocumentPage(
id: string,
title: string,
path: string,
pages: unknown[] = []
): RevisionPageDocument {
return {
object: 'page',
id,
type: 'document',
kind: 'sheet',
title,
path,
slug: path.split('/').pop() ?? path,
pages,
tags: [],
layout: {},
urls: { app: `https://app.gitbook.com/page/${id}` },
} as unknown as RevisionPageDocument;
}
function buildRevision(pages: unknown[]): Revision {
return {
object: 'revision',
id: 'rev-space-target',
type: 'edits',
pages,
files: [],
reusableContents: [],
tags: [],
parents: [],
createdAt: '',
urls: { app: '' },
} as unknown as Revision;
}
function buildCrossSpaceContext(
targetSpace: Space,
targetRevision: Revision,
structure: unknown
) {
const currentSiteSpace = buildSiteSpace(
buildSpace('space-current', 'Current Space'),
'Current Variant'
);
const dataFetcher = {
getSpace: async ({ spaceId }: { spaceId: string }) =>
spaceId === targetSpace.id
? { data: targetSpace }
: { error: { code: 404, message: 'Not found' } },
getRevision: async ({ spaceId }: { spaceId: string }) =>
spaceId === targetSpace.id
? { data: targetRevision }
: { error: { code: 404, message: 'Not found' } },
getChangeRequest: async () => ({ error: { code: 404, message: 'Not found' } }),
withToken: function () {
return this;
},
} as unknown as GitBookDataFetcher;
return buildContext({
siteSpace: currentSiteSpace,
structure,
dataFetcher,
});
}
it('prepends every localized section group and section to cross-space page ancestors', async () => {
const targetSpace = buildSpace('space-target', 'Target Space');
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
const pageGroup = {
object: 'page',
id: 'page-group',
type: 'group',
kind: 'group',
title: 'Getting started',
path: 'getting-started',
slug: 'getting-started',
pages: [buildDocumentPage('page-target', 'Target page', 'getting-started/target')],
};
const section = {
object: 'site-section',
id: 'section-target',
title: 'Reference',
localizedTitle: { fr: 'Référence' },
draft: false,
path: 'reference',
siteSpaces: [targetSiteSpace],
urls: {},
};
const sectionGroup = {
object: 'site-section-group',
id: 'section-group-target',
title: 'Product documentation',
localizedTitle: { fr: 'Documentation produit' },
draft: false,
sections: [],
children: [
{
object: 'site-section-group',
id: 'section-group-nested',
title: 'API guides',
draft: false,
sections: [],
children: [
{
object: 'site-section-group',
id: 'section-group-child',
title: 'Authentication',
localizedTitle: { fr: 'Authentification' },
draft: false,
sections: [section],
children: [section],
},
],
},
],
};
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
type: 'sections',
structure: [sectionGroup],
});
const result = await resolveContentRef(
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
{ ...context, locale: 'fr' } as GitBookAnyContext
);
expect(result?.text).toBe('Target page');
expect(result?.ancestors).toEqual([
{ label: 'Documentation produit' },
{ label: 'API guides' },
{ label: 'Authentification' },
{ label: 'Référence', href: targetSiteSpace.urls.published },
{ label: 'Getting started', icon: null, href: expect.any(String) },
]);
expect(result?.ancestors?.[0]?.href).toBeUndefined();
expect(result?.ancestors?.[1]?.href).toBeUndefined();
expect(result?.ancestors?.[2]?.href).toBeUndefined();
expect(result?.ancestors?.[4]?.href).toBeTruthy();
});
it('uses the first document as the target for a page-group reference', async () => {
const targetSpace = buildSpace('space-target', 'Target Space');
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
const pageGroup = {
object: 'page',
id: 'page-group',
type: 'group',
kind: 'group',
title: 'Learn about Cortex Agentix',
path: 'cortex-agentix',
slug: 'cortex-agentix',
pages: [buildDocumentPage('page-target', 'Cortex Agentix docs', 'cortex-agentix/docs')],
};
const context = buildCrossSpaceContext(targetSpace, buildRevision([pageGroup]), {
type: 'siteSpaces',
structure: [targetSiteSpace],
});
const result = await resolveContentRef(
{ kind: 'page', space: targetSpace.id, page: 'page-group' },
context
);
expect(result?.page?.id).toBe('page-target');
expect(result?.text).toBe('Learn about Cortex Agentix');
expect(result?.ancestors?.map((ancestor) => ancestor.label)).toEqual([
'Target Variant',
'Learn about Cortex Agentix',
]);
expect(
result?.ancestors?.filter(({ label }) => label === 'Learn about Cortex Agentix')
).toHaveLength(1);
});
it('uses the section instead of the variant for an ungrouped cross-space page', async () => {
const targetSpace = buildSpace('space-target', 'Target Space');
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
const section = {
object: 'site-section',
id: 'section-target',
title: 'Reference',
draft: false,
path: 'reference',
siteSpaces: [targetSiteSpace],
urls: {},
};
const context = buildCrossSpaceContext(
targetSpace,
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
{ type: 'sections', structure: [section] }
);
const result = await resolveContentRef(
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
context
);
expect(result?.ancestors).toEqual([
{ label: 'Reference', href: targetSiteSpace.urls.published },
]);
});
it('falls back to the target variant for a sectionless in-site page', async () => {
const targetSpace = buildSpace('space-target', 'Target Space');
const targetSiteSpace = buildSiteSpace(targetSpace, 'Target Variant');
targetSiteSpace.urls = { published: 'https://docs.example.com/target/' };
const context = buildCrossSpaceContext(
targetSpace,
buildRevision([buildDocumentPage('page-target', 'Target page', 'target')]),
{ type: 'siteSpaces', structure: [targetSiteSpace] }
);
const result = await resolveContentRef(
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
context
);
expect(result?.ancestors).toEqual([
{ label: 'Target Variant', href: targetSiteSpace.urls.published },
]);
});
it('falls back to the raw space title for an external page', async () => {
const targetSpace = buildSpace('space-external', 'External Space');
const context = buildCrossSpaceContext(
targetSpace,
buildRevision([buildDocumentPage('page-target', 'External page', 'target')]),
{ type: 'siteSpaces', structure: [] }
);
const result = await resolveContentRef(
{ kind: 'page', space: targetSpace.id, page: 'page-target' },
context
);
expect(result?.text).toBe('External page');
expect(result?.ancestors).toEqual([
{ label: 'External Space', href: targetSpace.urls.published },
]);
});
});
+49 -12
View File
@@ -512,6 +512,42 @@ function getSpaceRefSectionLabel(
return null;
}
/**
* Ancestors to attach to a resolved content ref. Page/anchor links identify their
* containing section instead of repeating the target variant.
*/
function resolvePageAncestors(
context: GitBookAnyContext,
contentRef: ContentRef,
foundSiteSpace: ReturnType<typeof findSiteSpaceBy>,
ctx: { spaceContext: GitBookSpaceContext; baseURL: URL }
): { label: string; href?: string }[] {
const isPageOrAnchorRef = contentRef.kind === 'page' || contentRef.kind === 'anchor';
if (isPageOrAnchorRef && foundSiteSpace?.siteSection) {
return [
...(foundSiteSpace.siteSectionGroups ?? []).map((group) => ({
label: getLocalizedTitle(group, context.locale),
})),
{
label: getLocalizedTitle(foundSiteSpace.siteSection, context.locale),
href: ctx.baseURL.toString(),
},
];
}
if (foundSiteSpace?.siteSpace) {
return [
{
label: getLocalizedTitle(foundSiteSpace.siteSpace, context.locale),
href: ctx.baseURL.toString(),
},
];
}
return [{ label: ctx.spaceContext.space.title, href: ctx.baseURL.toString() }];
}
async function resolveContentRefInSpace(
spaceId: string,
context: GitBookAnyContext,
@@ -546,19 +582,21 @@ async function resolveContentRefInSpace(
return null;
}
// Prefer the variant title when available, then the section title, then fallback to the space title.
const foundSiteSpace =
'site' in context
? findSiteSpaceBy(context.structure, (siteSpace) => siteSpace.space.id === spaceId)
: null;
const ancestors = resolvePageAncestors(context, contentRef, foundSiteSpace, ctx);
// Prefer the variant title when available, then the section title, then fallback to the space title for non-page refs.
const ancestorLabel = (() => {
if ('site' in context) {
const currentLanguage = context.locale;
const foundSiteSpace = findSiteSpaceBy(
context.structure,
(siteSpace) => siteSpace.space.id === spaceId
);
if (foundSiteSpace?.siteSpace) {
return getLocalizedTitle(foundSiteSpace.siteSpace, currentLanguage);
return getLocalizedTitle(foundSiteSpace.siteSpace, context.locale);
}
if (foundSiteSpace?.siteSection) {
return getLocalizedTitle(foundSiteSpace.siteSection, currentLanguage);
return getLocalizedTitle(foundSiteSpace.siteSection, context.locale);
}
return ctx.spaceContext.space.title;
}
@@ -569,10 +607,9 @@ async function resolveContentRefInSpace(
return {
...resolved,
ancestors: [
{
label: ancestorLabel,
href: ctx.baseURL.toString(),
},
...(contentRef.kind === 'page' || contentRef.kind === 'anchor'
? ancestors
: [{ label: ancestorLabel, href: ctx.baseURL.toString() }]),
...(resolved.ancestors ?? []),
].filter(filterOutNullable),
};
@@ -22,6 +22,8 @@ export function selectRankAttribute(rank: number): string {
}
// DOM contract applied by consumer blocks (tabs, cards, …) and read by the generated CSS.
// Option panes must be direct children of the element carrying the set class: the generated
// selectors use a child combinator, so a group never resolves the panes of a group nested in it.
/** Marks a group of mutually-exclusive options (e.g. a tab group). */
export const SELECT_GROUP_ATTR = 'data-select-group';
@@ -73,6 +73,10 @@ function escapeCssString(value: string): string {
* chains. `depth` must cover every rank the store can produce — visibility is CSS-only, so a winner
* beyond `depth` would fall back to its default — hence it defaults to {@link SELECT_LIST_CAP}.
*
* Every rule matches `& > …` rather than a descendant: a nested group's panes are also
* descendants of the outer group, so a descendant combinator would let the outer sheet hide them.
* The child combinator adds no specificity, leaving the source-order priority above intact.
*
* Returns `''` for an empty/degenerate set.
*/
export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_CAP): string {
@@ -86,20 +90,20 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
// All rules nest under the scope class; `&` stands in for it (see nesting note above).
const rules: string[] = [
// Hide every option, then reveal the default. Both are overridden below when a slug is active.
`${option}{display:none}`,
`[${SELECT_DEFAULT_ATTR}]{display:block}`,
`& > ${option}{display:none}`,
`& > [${SELECT_DEFAULT_ATTR}]{display:block}`,
];
for (let rank = depth - 1; rank >= 0; rank--) {
const attr = selectRankAttribute(rank);
const anyAtRank = slugs.map((slug) => `[${attr}="${escapeCssString(slug)}"]`).join(',');
// When any of the set's options sits at this rank, hide the group's panes...
rules.push(`html:is(${anyAtRank}) & ${option}{display:none}`);
rules.push(`html:is(${anyAtRank}) & > ${option}{display:none}`);
// ...then reveal whichever one matches (correlated, so a per-option list).
const show = slugs
.map((slug) => {
const value = escapeCssString(slug);
return `html[${attr}="${value}"] & [${SELECT_OPTION_ATTR}="${value}"]`;
return `html[${attr}="${value}"] & > [${SELECT_OPTION_ATTR}="${value}"]`;
})
.join(',');
rules.push(`${show}{display:block}`);
@@ -112,14 +116,14 @@ export function generateSelectCSS(candidateSlugs: string[], depth = SELECT_LIST_
for (const slug of slugs) {
const value = escapeCssString(slug);
const pane = `[${SELECT_OPTION_ATTR}="${value}"]`;
rules.push(`html & ${pane} ~ ${pane}{display:none}`);
rules.push(`html & > ${pane} ~ ${pane}{display:none}`);
}
// A client click can override that first-match default: it pins the picked pane and unpins its
// same-slug siblings so the visitor sees exactly the duplicate they clicked (reload reverts to
// first-match since these attributes aren't persisted). Emitted last to win at equal specificity.
rules.push(`html & ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
rules.push(`html & ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
rules.push(`html & > ${option}[${SELECT_PINNED_ATTR}]{display:block}`);
rules.push(`html & > ${option}[${SELECT_UNPINNED_ATTR}]{display:none}`);
return `.${selectSetClassName(slugs)}{${rules.join('')}}`;
}
+33
View File
@@ -13,6 +13,7 @@ import { TranslationLanguage } from '@gitbook/api';
import { createLinker } from './links';
import {
filterSiteSpacesByLocale,
findSiteSpaceBy,
getFallbackSiteSpacePath,
getLinkerForSiteSpace,
getSiteStructureSections,
@@ -79,6 +80,38 @@ describe('site structure traversal', () => {
]);
expect(listAllSiteSpaces(structure)).toEqual([rootSpace, nestedSpace]);
});
it('returns every section group from the root to the immediate parent', () => {
const targetSpace = { id: 'target-space' } as SiteSpace;
const targetSection = {
object: 'site-section',
id: 'target-section',
siteSpaces: [targetSpace],
} as SiteSection;
const childGroup = {
object: 'site-section-group',
id: 'child-group',
children: [targetSection],
} as SiteSectionGroup;
const firstChildGroup = {
object: 'site-section-group',
id: 'first-child-group',
children: [childGroup],
} as SiteSectionGroup;
const rootGroup = {
object: 'site-section-group',
id: 'root-group',
children: [firstChildGroup],
} as SiteSectionGroup;
const found = findSiteSpaceBy(
{ type: 'sections', structure: [rootGroup] },
(siteSpace) => siteSpace.id === targetSpace.id
);
expect(found?.siteSectionGroup).toBe(childGroup);
expect(found?.siteSectionGroups).toEqual([rootGroup, firstChildGroup, childGroup]);
});
});
describe('filterSiteSpacesByLocale', () => {
+15 -2
View File
@@ -264,6 +264,7 @@ export function findSiteSpaceBy(
siteSpace: SiteSpace;
siteSection: SiteSection | null;
siteSectionGroup: SiteSectionGroup | null;
siteSectionGroups: SiteSectionGroup[];
} | null {
if (siteStructure.type === 'siteSpaces') {
const siteSpace = siteStructure.structure.find(predicate) ?? null;
@@ -272,6 +273,7 @@ export function findSiteSpaceBy(
siteSpace,
siteSection: null,
siteSectionGroup: null,
siteSectionGroups: [],
};
}
@@ -290,6 +292,7 @@ export function findSiteSpaceBy(
siteSpace,
siteSection: sectionOrGroup,
siteSectionGroup: null,
siteSectionGroups: [],
};
}
break;
@@ -361,11 +364,14 @@ export function getFallbackSiteSpacePath(context: GitBookSiteContext, siteSpace:
function findSiteSpaceByIdInGroupChildren(
children: SiteStructureNode[],
predicate: (siteSpace: SiteSpace) => boolean,
parentGroup: SiteSectionGroup
parentGroup: SiteSectionGroup,
rootGroup: SiteSectionGroup = parentGroup,
sectionGroups: SiteSectionGroup[] = [parentGroup]
): {
siteSpace: SiteSpace;
siteSection: SiteSection;
siteSectionGroup: SiteSectionGroup;
siteSectionGroups: SiteSectionGroup[];
} | null {
for (const child of children) {
switch (child.object) {
@@ -376,12 +382,19 @@ function findSiteSpaceByIdInGroupChildren(
siteSpace,
siteSection: child,
siteSectionGroup: parentGroup,
siteSectionGroups: sectionGroups,
};
}
break;
}
case 'site-section-group': {
const found = findSiteSpaceByIdInGroupChildren(child.children, predicate, child);
const found = findSiteSpaceByIdInGroupChildren(
child.children,
predicate,
child,
rootGroup,
[...sectionGroups, child]
);
if (found) {
return found;
}
+9
View File
@@ -13,6 +13,7 @@ import {
SiteInsightsDisplayContext,
type SiteInsightsEventLocation,
SiteInsightsLLMSVariant,
SiteInsightsMarkdownSource,
} from '@gitbook/api';
import {
@@ -533,6 +534,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
if (rewrittenURL.searchParams.has('displayAgentInstructions')) {
rewrittenURL.searchParams.delete('displayAgentInstructions');
}
if (rewrittenURL.searchParams.has('markdownSource')) {
rewrittenURL.searchParams.delete('markdownSource');
}
const response = NextResponse.rewrite(rewrittenURL, {
request: {
@@ -897,6 +901,10 @@ function encodePathInSiteContent(
// It is encoded as a second path segment (the route is statically rendered, so it can't
// read query params at runtime — the question is path-encoded for the same reason).
const goal = searchParams.get('goal');
// Validated: this is user input going into insights.
const markdownSource = Object.values(SiteInsightsMarkdownSource).find(
(source) => source === searchParams.get('markdownSource')
);
return {
pathname:
typeof ask === 'string'
@@ -922,6 +930,7 @@ function encodePathInSiteContent(
: [
{
type: 'page_markdown_request',
...(markdownSource ? { markdownSource } : {}),
location: {
displayContext: SiteInsightsDisplayContext.Server,
},