diff --git a/bun.lock b/bun.lock index 3d06536917..3424d892de 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "gitbook", @@ -135,6 +134,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", + "@tanstack/react-virtual": "^3.13.12", "@tusbar/cache-control": "^1.0.2", "ai": "^4.2.2", "assert-never": "catalog:", @@ -1478,7 +1478,9 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.11", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "postcss": "^8.4.41", "tailwindcss": "4.1.11" } }, "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA=="], - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.10.8", "", {}, "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], "@tanstack/vue-virtual": ["@tanstack/vue-virtual@3.10.8", "", { "dependencies": { "@tanstack/virtual-core": "3.10.8" }, "peerDependencies": { "vue": "^2.7.0 || ^3.0.0" } }, "sha512-DB5QA8c/LfqOqIUCpSs3RdOTVroRRdqeHMqBkYrcashSZtOzIv8xbiqHgg7RYxDfkH5F3Y+e0MkuuyGNDVB0BQ=="], @@ -4034,6 +4036,8 @@ "@tailwindcss/postcss/postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + "@tanstack/vue-virtual/@tanstack/virtual-core": ["@tanstack/virtual-core@3.10.8", "", {}, "sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA=="], + "@ts-morph/common/fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="], "@ts-morph/common/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 7a0b65adea..da54329a0f 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -29,6 +29,7 @@ "@sindresorhus/fnv1a": "^3.1.0", "@tailwindcss/container-queries": "^0.1.1", "@tusbar/cache-control": "^1.0.2", + "@tanstack/react-virtual": "^3.13.12", "ai": "^4.2.2", "assert-never": "catalog:", "bidc": "catalog:", diff --git a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx index ccadb8391a..1aed535acd 100644 --- a/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/RecordRow.tsx @@ -1,7 +1,5 @@ +import { type ClassValue, tcls } from '@/lib/tailwind'; import type { DocumentTableViewGrid } from '@gitbook/api'; - -import { tcls } from '@/lib/tailwind'; - import { RecordColumnValue } from './RecordColumnValue'; import type { TableRecordKV, TableViewProps } from './Table'; import { getColumnWidth } from './ViewGrid'; @@ -13,12 +11,13 @@ export function RecordRow( record: TableRecordKV; autoSizedColumns: string[]; fixedColumns: string[]; + className?: ClassValue; } ) { - const { view, autoSizedColumns, fixedColumns, block, context } = props; + const { view, autoSizedColumns, fixedColumns, block, context, className } = props; return ( -
+
{view.columns.map((column) => { const columnWidth = getColumnWidth({ column, diff --git a/packages/gitbook/src/components/DocumentView/Table/RowGroupVirtualized.tsx b/packages/gitbook/src/components/DocumentView/Table/RowGroupVirtualized.tsx new file mode 100644 index 0000000000..f4a3c1c720 --- /dev/null +++ b/packages/gitbook/src/components/DocumentView/Table/RowGroupVirtualized.tsx @@ -0,0 +1,105 @@ +import { tcls } from '@/lib/tailwind'; +import type { DocumentTableViewGrid } from '@gitbook/api'; +import { useWindowVirtualizer } from '@tanstack/react-virtual'; +import React from 'react'; +import { RecordRow } from './RecordRow'; +import type { TableViewProps } from './Table'; + +/** + * Virtualized group of rows for tables with many records. + */ +export const VIRTUALIZATION_THRESHOLD = 200; +const ROW_ESTIMATE_PX = 40; + +export function RowGroupVirtualized( + props: TableViewProps & { + autoSizedColumns: string[]; + fixedColumns: string[]; + tableWidth: string; + } +) { + const { records, tableWidth, ...rest } = props; + const [isHydrated, setIsHydrated] = React.useState(false); + const [scrollMargin, setScrollMargin] = React.useState(0); + const parentRef = React.useRef(null); + + React.useEffect(() => { + setIsHydrated(true); + }, []); + + React.useEffect(() => { + if (!isHydrated) { + return; + } + + const updateScrollMargin = () => { + if (!parentRef.current) { + return; + } + + const rect = parentRef.current.getBoundingClientRect(); + setScrollMargin(rect.top + window.scrollY); + }; + + updateScrollMargin(); + window.addEventListener('resize', updateScrollMargin); + + return () => { + window.removeEventListener('resize', updateScrollMargin); + }; + }, [isHydrated]); + + const rowVirtualizer = useWindowVirtualizer({ + count: records.length, + estimateSize: () => ROW_ESTIMATE_PX, + overscan: 8, + scrollMargin, + }); + + if (!isHydrated) { + return ( +
+ {records.map((record) => ( + + ))} +
+ ); + } + + return ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const record = records[virtualRow.index]; + + if (!record) { + return null; + } + + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx index 0ff9584df2..dc0861d349 100644 --- a/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx +++ b/packages/gitbook/src/components/DocumentView/Table/ViewGrid.tsx @@ -3,6 +3,7 @@ import type { DocumentTableViewGrid } from '@gitbook/api'; import { tcls } from '@/lib/tailwind'; import { RecordRow } from './RecordRow'; +import { RowGroupVirtualized, VIRTUALIZATION_THRESHOLD } from './RowGroupVirtualized'; import type { TableViewProps } from './Table'; import styles from './table.module.css'; import { getColumnAlignment } from './utils'; @@ -13,7 +14,7 @@ import { getColumnAlignment } from './utils'; 3. Auto-size is turned off without setting a width, we then default to a fixed width of 100px */ export function ViewGrid(props: TableViewProps) { - const { block, view, records, style, context } = props; + const { block, view, records, style, context, isOffscreen } = props; /* Calculate how many columns are auto-sized vs fixed width */ const columnWidths = context.mode === 'print' ? undefined : view.columnWidths; @@ -29,6 +30,9 @@ export function ViewGrid(props: TableViewProps) { (columnId) => (block.data.definition[columnId]?.title.trim().length ?? 0) > 0 ); + const shouldVirtualize = + context.mode !== 'print' && !isOffscreen && records.length >= VIRTUALIZATION_THRESHOLD; + return (
{/* Table */} @@ -73,20 +77,29 @@ export function ViewGrid(props: TableViewProps) {
)} -
*+*]:border-t')} - > - {records.map((record) => ( - - ))} -
+ {shouldVirtualize ? ( + + ) : ( +
*+*]:border-t')} + > + {records.map((record) => ( + + ))} +
+ )}
);