Skip to content

Commit e3c8adb

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

File tree

5 files changed

+161
-28
lines changed

5 files changed

+161
-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: 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';
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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+
// @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 either light or dark image
41+
const hasDimensions = imgs.light.size || imgs.dark?.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 light first, then dark, then loaded dimensions
69+
const imageDimensions = imgs.light.size ?? imgs.dark?.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 clampedObjectPositionY = useCallback(
90+
(offset: number): number => {
91+
if (!container.height || !imageDimensions) {
92+
return 50;
93+
}
94+
95+
const scaled = imageDimensions.height * safeRatio;
96+
if (scaled <= container.height || maxOffset === 0) {
97+
return 50;
98+
}
99+
100+
const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, offset));
101+
const relative = (maxOffset - clampedOffset) / (2 * maxOffset);
102+
return relative * 100;
103+
},
104+
[container.height, imageDimensions, maxOffset, safeRatio]
105+
);
106+
107+
const objectPositionY = clampedObjectPositionY(y);
108+
109+
return {
110+
containerRef,
111+
objectPositionY,
112+
isLoading: !imageDimensions || isLoading,
113+
};
114+
}

0 commit comments

Comments
 (0)