Skip to content

Commit e842e50

Browse files
committed
Support cover image heights and positioning changes
1 parent 6c98939 commit e842e50

File tree

5 files changed

+162
-28
lines changed

5 files changed

+162
-28
lines changed

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

Lines changed: 8 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,12 @@ 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,
@@ -78,6 +85,7 @@ export async function PageCover(props: {
7885
<div
7986
id="page-cover"
8087
data-full={String(as === 'full')}
88+
style={{ height: `${height}px` }}
8189
className={tcls(
8290
'overflow-hidden',
8391
// Negative margin to balance the container padding

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

Lines changed: 13 additions & 28 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;
@@ -18,28 +17,16 @@ interface Images {
1817
dark?: ImageAttributes;
1918
}
2019

21-
const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 };
22-
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-
3620
export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
37-
const containerRef = useRef<HTMLDivElement>(null);
21+
const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y);
3822

39-
const container = useResizeObserver({
40-
// @ts-expect-error wrong types
41-
ref: containerRef,
42-
});
23+
if (isLoading) {
24+
return (
25+
<div className="h-full w-full overflow-hidden" ref={containerRef}>
26+
<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" />
27+
</div>
28+
);
29+
}
4330

4431
return (
4532
<div className="h-full w-full overflow-hidden" ref={containerRef}>
@@ -49,10 +36,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
4936
sizes={imgs.light.sizes}
5037
fetchPriority="high"
5138
alt="Page cover"
52-
className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
39+
className={tcls('h-full', 'w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')}
5340
style={{
54-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
55-
objectPosition: `50% ${getTop(container, y, imgs.light)}`,
41+
objectPosition: `50% ${objectPositionY}%`,
5642
}}
5743
/>
5844
{imgs.dark && (
@@ -62,10 +48,9 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) {
6248
sizes={imgs.dark.sizes}
6349
fetchPriority="low"
6450
alt="Page cover"
65-
className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')}
51+
className={tcls('h-full', 'w-full', 'object-cover', 'dark:inline', 'hidden')}
6652
style={{
67-
aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`,
68-
objectPosition: `50% ${getTop(container, y, imgs.dark)}`,
53+
objectPosition: `50% ${objectPositionY}%`,
6954
}}
7055
/>
7156
)}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
type CoverWithHeight = RevisionPageDocumentCover & { height?: number | null };
8+
9+
// Normalize and clamp the cover height between the minimum and maximum heights
10+
function clampCoverHeight(height: number | null | undefined): number {
11+
if (typeof height !== 'number' || Number.isNaN(height)) {
12+
return DEFAULT_COVER_HEIGHT;
13+
}
14+
15+
return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height));
16+
}
17+
18+
export function getCoverHeight(
19+
cover: RevisionPageDocumentCover | null | undefined
20+
): number | undefined {
21+
// Cover (and thus height) is not defined
22+
if (!cover) {
23+
return undefined;
24+
}
25+
26+
return clampCoverHeight((cover as CoverWithHeight).height ?? DEFAULT_COVER_HEIGHT);
27+
}
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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use client';
2+
import { useCallback, useLayoutEffect, 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+
ref: containerRef,
35+
});
36+
37+
// Load original image dimensions if not provided in `imgs`
38+
useLayoutEffect(() => {
39+
// Check if we have dimensions from either light or dark image
40+
const hasDimensions = imgs.light.size || imgs.dark?.size;
41+
42+
if (hasDimensions) {
43+
return; // Already have dimensions
44+
}
45+
46+
setIsLoading(true);
47+
48+
// Load the original image (using src, not srcSet) to get true dimensions
49+
// Use dark image if available, otherwise fall back to light
50+
const imageToLoad = imgs.dark || imgs.light;
51+
const img = new Image();
52+
img.onload = () => {
53+
setLoadedDimensions({
54+
width: img.naturalWidth,
55+
height: img.naturalHeight,
56+
});
57+
setIsLoading(false);
58+
};
59+
img.onerror = () => {
60+
// If image fails to load, use a fallback
61+
setIsLoading(false);
62+
};
63+
img.src = imageToLoad.src;
64+
}, [imgs.light, imgs.dark]);
65+
66+
// Use provided dimensions or fall back to loaded dimensions
67+
// Check light first, then dark, then loaded dimensions
68+
const imageDimensions = imgs.light.size ?? imgs.dark?.size ?? loadedDimensions;
69+
70+
// Calculate ratio and dimensions similar to useCoverPosition hook
71+
const ratio =
72+
imageDimensions && container.height && container.width
73+
? Math.max(
74+
container.width / imageDimensions.width,
75+
container.height / imageDimensions.height
76+
)
77+
: 1;
78+
const safeRatio = ratio || 1;
79+
80+
const scaledHeight =
81+
imageDimensions && container.height ? imageDimensions.height * safeRatio : null;
82+
const maxOffset =
83+
scaledHeight && container.height
84+
? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio)
85+
: 0;
86+
87+
// Parse the position between the allowed min/max
88+
const clampedObjectPositionY = useCallback(
89+
(offset: number): number => {
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, offset));
100+
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
101+
return relative * 100;
102+
},
103+
[container.height, imageDimensions, maxOffset, safeRatio]
104+
);
105+
106+
const objectPositionY = clampedObjectPositionY(y);
107+
108+
return {
109+
containerRef,
110+
objectPositionY,
111+
isLoading: !imageDimensions || isLoading,
112+
};
113+
}

0 commit comments

Comments
 (0)