Skip to content

Commit 35dcaca

Browse files
Support custom cover heights (#3779)
1 parent 2aa4f2e commit 35dcaca

File tree

7 files changed

+187
-27
lines changed

7 files changed

+187
-27
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: 4 additions & 0 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,6 +23,8 @@ export async function PageCover(props: {
2223
context: GitBookSiteContext;
2324
}) {
2425
const { as, page, cover, context } = props;
26+
const height = getCoverHeight(cover);
27+
2528
const [resolved, resolvedDark] = await Promise.all([
2629
cover.ref ? resolveContentRef(cover.ref, context) : null,
2730
cover.refDark ? resolveContentRef(cover.refDark, context) : null,
@@ -108,6 +111,7 @@ export async function PageCover(props: {
108111
dark,
109112
}}
110113
y={cover.yPos}
114+
height={height}
111115
/>
112116
</div>
113117
);

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

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
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 { useCoverPosition } from './useCoverPosition';
65

76
interface ImageAttributes {
87
src: string;
@@ -20,29 +19,27 @@ interface Images {
2019

2120
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
2221

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`;
22+
interface PageCoverImageProps {
23+
imgs: Images;
24+
y: number;
25+
// Only if the `height` was customized by the user (and thus defined), we use it to set the cover's height and skip the default behaviour of fixed aspect-ratio.
26+
height: number | undefined;
3427
}
3528

36-
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
37-
const containerRef = useRef<HTMLDivElement>(null);
29+
export function PageCoverImage(props: PageCoverImageProps) {
30+
const { imgs, y, height } = props;
31+
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
3832

39-
const container = useResizeObserver({
40-
// @ts-expect-error wrong types
41-
ref: containerRef,
42-
});
33+
if (isLoading) {
34+
return (
35+
<div className="h-full w-full overflow-hidden" ref={containerRef}>
36+
<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" />
37+
</div>
38+
);
39+
}
4340

4441
return (
45-
<div className="h-full w-full overflow-hidden" ref={containerRef}>
42+
<div className="h-full w-full overflow-hidden" ref={containerRef} style={{ height }}>
4643
<img
4744
src={imgs.light.src}
4845
srcSet={imgs.light.srcSet}
@@ -51,8 +48,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
5148
alt="Page cover"
5249
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
5350
style={{
54-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
55-
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
51+
aspectRatio: height
52+
? undefined
53+
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
54+
objectPosition: `50% ${objectPositionY}%`,
55+
height, // if no height is passed, no height will be set.
5656
}}
5757
/>
5858
{imgs.dark && (
@@ -64,8 +64,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
6464
alt="Page cover"
6565
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
6666
style={{
67-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
68-
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
67+
aspectRatio: height
68+
? undefined
69+
: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
70+
objectPosition: `50% ${objectPositionY}%`,
71+
height, // if no height is passed, no height will be set.
6972
}}
7073
/>
7174
)}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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+
// When a user set a custom cover height, we return the clamped cover height. If no height is set, we want to preserve the existing logic for sizing of the cover image and return `undefined` for height.
17+
export function getCoverHeight(
18+
cover: RevisionPageDocumentCover | null | undefined
19+
): number | undefined {
20+
// Cover (and thus height) is not defined
21+
if (!cover || !cover.height) {
22+
return undefined;
23+
}
24+
25+
return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT);
26+
}
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';
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
'use client';
2+
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
3+
import { useResizeObserver } from 'usehooks-ts';
4+
5+
interface ImageSize {
6+
width: number;
7+
height: number;
8+
}
9+
10+
interface ImageAttributes {
11+
src: string;
12+
srcSet?: string;
13+
sizes?: string;
14+
width?: number;
15+
height?: number;
16+
size?: ImageSize;
17+
}
18+
19+
interface Images {
20+
light: ImageAttributes;
21+
dark?: ImageAttributes;
22+
}
23+
24+
/**
25+
* Hook to calculate the object position Y percentage for a cover image
26+
* based on the y offset, image dimensions, and container dimensions.
27+
*/
28+
export function useCoverPosition(imgs: Images, y: number) {
29+
const containerRef = useRef<HTMLDivElement>(null);
30+
const [loadedDimensions, setLoadedDimensions] = useState<ImageSize | null>(null);
31+
const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size);
32+
33+
const container = useResizeObserver({
34+
// @ts-expect-error wrong types
35+
ref: containerRef,
36+
});
37+
38+
// Load original image dimensions if not provided in `imgs`
39+
useLayoutEffect(() => {
40+
// Check if we have dimensions from dark (if provided) or else the default light.
41+
const hasDimensions = imgs.dark?.size || imgs.light.size;
42+
43+
if (hasDimensions) {
44+
return; // Already have dimensions
45+
}
46+
47+
setIsLoading(true);
48+
49+
// Load the original image (using src, not srcSet) to get true dimensions
50+
// Use dark image if available, otherwise fall back to light
51+
const imageToLoad = imgs.dark || imgs.light;
52+
const img = new Image();
53+
img.onload = () => {
54+
setLoadedDimensions({
55+
width: img.naturalWidth,
56+
height: img.naturalHeight,
57+
});
58+
setIsLoading(false);
59+
};
60+
img.onerror = () => {
61+
// If image fails to load, use a fallback
62+
setIsLoading(false);
63+
};
64+
img.src = imageToLoad.src;
65+
}, [imgs.light, imgs.dark]);
66+
67+
// Use provided dimensions or fall back to loaded dimensions
68+
// Check dark first, then light, then loaded dimensions
69+
const imageDimensions = imgs.dark?.size ?? imgs.light.size ?? loadedDimensions;
70+
71+
// Calculate ratio and dimensions similar to useCoverPosition hook
72+
const ratio =
73+
imageDimensions && container.height && container.width
74+
? Math.max(
75+
container.width / imageDimensions.width,
76+
container.height / imageDimensions.height
77+
)
78+
: 1;
79+
const safeRatio = ratio || 1;
80+
81+
const scaledHeight =
82+
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
83+
const maxOffset =
84+
scaledHeight && container.height
85+
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
86+
: 0;
87+
88+
// Parse the position between the allowed min/max
89+
const objectPositionY = useMemo(() => {
90+
if (!container.height || !imageDimensions) {
91+
return 50;
92+
}
93+
94+
const scaled = imageDimensions.height * safeRatio;
95+
if (scaled <= container.height || maxOffset === 0) {
96+
return 50;
97+
}
98+
99+
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y));
100+
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
101+
return relative * 100;
102+
}, [container.height, imageDimensions, maxOffset, safeRatio, y]);
103+
104+
return {
105+
containerRef,
106+
objectPositionY,
107+
isLoading: !imageDimensions || isLoading,
108+
};
109+
}

0 commit comments

Comments
 (0)