Skip to content

Commit 2c773b9

Browse files
committed
Support custom cover heights
1 parent 6c98939 commit 2c773b9

File tree

8 files changed

+269
-33
lines changed

8 files changed

+269
-33
lines changed

packages/gitbook/e2e/internal.spec.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
headerLinks,
3434
runTestCases,
3535
waitForCookiesDialog,
36+
waitForCoverImages,
3637
waitForNotFound,
3738
} from './util';
3839

@@ -906,7 +907,10 @@ const testCases: TestsCase[] = [
906907
{
907908
name: 'With cover',
908909
url: 'page-options/page-with-cover',
909-
run: waitForCookiesDialog,
910+
run: async (page) => {
911+
await waitForCookiesDialog(page);
912+
await waitForCoverImages(page);
913+
},
910914
},
911915
{
912916
name: 'With cover for dark mode',
@@ -921,12 +925,18 @@ const testCases: TestsCase[] = [
921925
{
922926
name: 'With hero cover',
923927
url: 'page-options/page-with-hero-cover',
924-
run: waitForCookiesDialog,
928+
run: async (page) => {
929+
await waitForCookiesDialog(page);
930+
await waitForCoverImages(page);
931+
},
925932
},
926933
{
927934
name: 'With cover and no TOC',
928935
url: 'page-options/page-with-cover-and-no-toc',
929-
run: waitForCookiesDialog,
936+
run: async (page) => {
937+
await waitForCookiesDialog(page);
938+
await waitForCoverImages(page);
939+
},
930940
screenshot: {
931941
waitForTOCScrolling: false,
932942
},

packages/gitbook/e2e/util.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) {
154154
expect(response?.status()).toBe(404);
155155
}
156156

157+
export async function waitForCoverImages(page: Page) {
158+
// Wait for cover images to exist (not the shimmer placeholder)
159+
await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({
160+
timeout: 10_000,
161+
});
162+
}
163+
157164
/**
158165
* Transform test cases into Playwright tests and run it.
159166
*/

packages/gitbook/src/components/PageBody/PageCover.tsx

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind';
88

99
import { assert } from 'ts-essentials';
1010
import { PageCoverImage } from './PageCoverImage';
11+
import { getCoverHeight } from './coverHeight';
1112
import defaultPageCoverSVG from './default-page-cover.svg';
1213

1314
const defaultPageCover = defaultPageCoverSVG as StaticImageData;
@@ -22,23 +23,36 @@ export async function PageCover(props: {
2223
context: GitBookSiteContext;
2324
}) {
2425
const { as, page, cover, context } = props;
26+
const height = getCoverHeight(cover);
27+
28+
if (!height) {
29+
return null;
30+
}
31+
2532
const [resolved, resolvedDark] = await Promise.all([
2633
cover.ref ? resolveContentRef(cover.ref, context) : null,
2734
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
2835
]);
2936

37+
// Calculate sizes based on cover type and page layout
38+
// Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages
39+
// Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens)
40+
const isWidePage = page.layout.width === 'wide';
41+
const maxWidth = as === 'full' ? 1920 : isWidePage ? 1536 : 768;
42+
3043
const sizes = [
31-
// Cover takes the full width on mobile/table
44+
// Cover takes the full width on mobile
3245
{
3346
media: '(max-width: 768px)',
3447
width: 768,
3548
},
49+
// Tablet sizes
3650
{
3751
media: '(max-width: 1024px)',
3852
width: 1024,
3953
},
40-
// Maximum size of the cover
41-
{ width: 1248 },
54+
// Maximum size based on cover type and page layout
55+
{ width: maxWidth },
4256
];
4357

4458
const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => {
@@ -108,6 +122,7 @@ export async function PageCover(props: {
108122
dark,
109123
}}
110124
y={cover.yPos}
125+
height={height}
111126
/>
112127
</div>
113128
);

packages/gitbook/src/components/PageBody/PageCoverImage.tsx

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
'use client';
22
import { tcls } from '@/lib/tailwind';
3-
import { useRef } from 'react';
4-
import { useResizeObserver } from 'usehooks-ts';
53
import type { ImageSize } from '../utils';
4+
import { getRecommendedCoverDimensions } from './coverDimensions';
5+
import { useCoverPosition } from './useCoverPosition';
66

77
interface ImageAttributes {
88
src: string;
@@ -18,31 +18,25 @@ interface Images {
1818
dark?: ImageAttributes;
1919
}
2020

21-
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
21+
export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; height: number }) {
22+
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
2223

23-
function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) {
24-
// When the size of the image hasn't been determined, we fallback to the center position
25-
if (!img.size || y === 0) return '50%';
26-
const ratio =
27-
container.height && container.width
28-
? Math.max(container.width / img.size.width, container.height / img.size.height)
29-
: 1;
30-
const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height;
31-
const top =
32-
container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y;
33-
return `${top}px`;
34-
}
35-
36-
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
37-
const containerRef = useRef<HTMLDivElement>(null);
24+
// Calculate the recommended aspect ratio for this height
25+
// This maintains the 4:1 ratio, allowing images to scale proportionally
26+
// and adapt their height when container width doesn't match the ideal ratio
27+
const recommendedDimensions = getRecommendedCoverDimensions(height);
28+
const aspectRatio = recommendedDimensions.width / recommendedDimensions.height;
3829

39-
const container = useResizeObserver({
40-
// @ts-expect-error wrong types
41-
ref: containerRef,
42-
});
30+
if (isLoading) {
31+
return (
32+
<div className="h-full w-full overflow-hidden" ref={containerRef}>
33+
<div className="h-full w-full animate-pulse bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-800 dark:to-gray-900" />
34+
</div>
35+
);
36+
}
4337

4438
return (
45-
<div className="h-full w-full overflow-hidden" ref={containerRef}>
39+
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
4640
<img
4741
src={imgs.light.src}
4842
srcSet={imgs.light.srcSet}
@@ -51,8 +45,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
5145
alt="Page cover"
5246
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
5347
style={{
54-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
55-
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
48+
aspectRatio: `${aspectRatio}`,
49+
objectPosition: `50% ${objectPositionY}%`,
5650
}}
5751
/>
5852
{imgs.dark && (
@@ -64,8 +58,8 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
6458
alt="Page cover"
6559
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
6660
style={{
67-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
68-
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
61+
aspectRatio: `${aspectRatio}`,
62+
objectPosition: `50% ${objectPositionY}%`,
6963
}}
7064
/>
7165
)}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Calculates the ideal cover image dimensions based on the cover type,
3+
* page layout, and viewport constraints.
4+
*
5+
* The dimensions are optimized for:
6+
* - Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages
7+
* - Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens)
8+
* - Responsive sizes: 768px (mobile), 1024px (tablet), up to 1920px+ (large desktop)
9+
*
10+
* The recommended aspect ratio is optimized to work well across all these scenarios.
11+
*/
12+
13+
export const DEFAULT_COVER_HEIGHT = 240;
14+
15+
/**
16+
* Calculate recommended cover image dimensions based on actual rendering widths.
17+
*
18+
* Analysis of actual rendering widths:
19+
* - Hero, regular page: max-w-3xl = 768px
20+
* - Hero, wide page: max-w-screen-2xl = 1536px
21+
* - Full cover (mobile): ~768px (full viewport minus padding)
22+
* - Full cover (tablet): ~1024px (full viewport minus padding)
23+
* - Full cover (desktop): Can be 768px (hero regular) to ~1920px (full wide) on large screens
24+
*
25+
* The recommended aspect ratio (4:1) is a standard format that works well for:
26+
* - Default height of 240px → 960px width (close to tablet size of 1024px)
27+
* - Works well when cropped to 768px (hero regular), 1024px (tablet), 1536px (hero wide), and 1920px (full wide)
28+
* - With `object-cover`, images maintain their natural aspect ratio while filling the container,
29+
* so the 4:1 ratio provides good coverage across all scenarios
30+
*
31+
* This ratio ensures images look good across all scenarios (hero/full, regular/wide, all viewports)
32+
* while maintaining good image quality for responsive srcSet generation and being easy to remember.
33+
*
34+
* @param height - The cover height in pixels (default: 240)
35+
* @returns Recommended width and height for the cover image
36+
*/
37+
export function getRecommendedCoverDimensions(height: number = DEFAULT_COVER_HEIGHT): {
38+
width: number;
39+
height: number;
40+
} {
41+
// Standard 4:1 aspect ratio - a common and easy-to-work-with format
42+
// At 240px height: 960px width
43+
// This ratio works well for:
44+
// - Hero covers on regular pages (768px width) - image will scale to cover
45+
// - Hero covers on wide pages (1536px width) - image will scale to cover
46+
// - Full covers across all breakpoints (768px - 1920px+) - image will scale proportionally
47+
//
48+
// Since we use `object-cover`, the image will scale to fill the container while maintaining
49+
// its aspect ratio, so the 4:1 ratio provides excellent coverage across all scenarios.
50+
//
51+
// Examples for different heights:
52+
// - 240px height → 960px width (recommended for default)
53+
// - 400px height → 1600px width (recommended for taller covers)
54+
// - 500px height → 2000px width (recommended for very tall covers)
55+
const aspectRatio = 4;
56+
57+
return {
58+
width: Math.round(height * aspectRatio),
59+
height,
60+
};
61+
}
62+
63+
/**
64+
* Get the maximum cover width based on cover type and page layout.
65+
* Used for determining the upper bound of image dimensions.
66+
*/
67+
export function getMaxCoverWidth(coverType: 'hero' | 'full', isWidePage: boolean): number {
68+
if (coverType === 'hero') {
69+
// Hero covers: max-w-3xl (768px) or max-w-screen-2xl (1536px)
70+
return isWidePage ? 1536 : 768;
71+
}
72+
// Full covers can expand to viewport width, typically up to 1920px on large screens
73+
// Accounting for some margins, we use 1920px as the maximum
74+
return 1920;
75+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { RevisionPageDocumentCover } from '@gitbook/api';
2+
3+
export const DEFAULT_COVER_HEIGHT = 240;
4+
export const MIN_COVER_HEIGHT = 10;
5+
export const MAX_COVER_HEIGHT = 700;
6+
7+
// Normalize and clamp the cover height between the minimum and maximum heights
8+
function clampCoverHeight(height: number | null | undefined): number {
9+
if (typeof height !== 'number' || Number.isNaN(height)) {
10+
return DEFAULT_COVER_HEIGHT;
11+
}
12+
13+
return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height));
14+
}
15+
16+
export function getCoverHeight(
17+
cover: RevisionPageDocumentCover | null | undefined
18+
): number | undefined {
19+
// Cover (and thus height) is not defined
20+
if (!cover) {
21+
return undefined;
22+
}
23+
24+
return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT);
25+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './PageBody';
22
export * from './PageCover';
3+
export * from './useCoverPosition';

0 commit comments

Comments
 (0)