diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 34088e136b..8ea1a361a1 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -33,6 +33,7 @@ import { headerLinks, runTestCases, waitForCookiesDialog, + waitForCoverImages, waitForNotFound, } from './util'; @@ -906,7 +907,10 @@ const testCases: TestsCase[] = [ { name: 'With cover', url: 'page-options/page-with-cover', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, }, { name: 'With cover for dark mode', @@ -921,12 +925,18 @@ const testCases: TestsCase[] = [ { name: 'With hero cover', url: 'page-options/page-with-hero-cover', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, }, { name: 'With cover and no TOC', url: 'page-options/page-with-cover-and-no-toc', - run: waitForCookiesDialog, + run: async (page) => { + await waitForCookiesDialog(page); + await waitForCoverImages(page); + }, screenshot: { waitForTOCScrolling: false, }, diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index c2b3a93004..6b62dea8c0 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -154,6 +154,13 @@ export async function waitForNotFound(_page: Page, response: Response | null) { expect(response?.status()).toBe(404); } +export async function waitForCoverImages(page: Page) { + // Wait for cover images to exist (not the shimmer placeholder) + await expect(page.locator('img[alt="Page cover"]').first()).toBeVisible({ + timeout: 10_000, + }); +} + /** * Transform test cases into Playwright tests and run it. */ diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index 0623310519..75871ac15f 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -8,6 +8,7 @@ import { tcls } from '@/lib/tailwind'; import { assert } from 'ts-essentials'; import { PageCoverImage } from './PageCoverImage'; +import { getCoverHeight } from './coverHeight'; import defaultPageCoverSVG from './default-page-cover.svg'; const defaultPageCover = defaultPageCoverSVG as StaticImageData; @@ -22,6 +23,8 @@ export async function PageCover(props: { context: GitBookSiteContext; }) { const { as, page, cover, context } = props; + const height = getCoverHeight(cover); + const [resolved, resolvedDark] = await Promise.all([ cover.ref ? resolveContentRef(cover.ref, context) : null, cover.refDark ? resolveContentRef(cover.refDark, context) : null, @@ -108,6 +111,7 @@ export async function PageCover(props: { dark, }} y={cover.yPos} + height={height} /> ); diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 22dc28f386..eb2190b744 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,8 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; -import { useRef } from 'react'; -import { useResizeObserver } from 'usehooks-ts'; import type { ImageSize } from '../utils'; +import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { src: string; @@ -20,29 +19,27 @@ interface Images { const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; -function getTop(container: { height?: number; width?: number }, y: number, img: ImageAttributes) { - // When the size of the image hasn't been determined, we fallback to the center position - if (!img.size || y === 0) return '50%'; - const ratio = - container.height && container.width - ? Math.max(container.width / img.size.width, container.height / img.size.height) - : 1; - const scaledHeight = img.size ? img.size.height * ratio : PAGE_COVER_SIZE.height; - const top = - container.height && img.size ? (container.height - scaledHeight) / 2 + y * ratio : y; - return `${top}px`; +interface PageCoverImageProps { + imgs: Images; + y: number; + // 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. + height: number | undefined; } -export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) { - const containerRef = useRef(null); +export function PageCoverImage(props: PageCoverImageProps) { + const { imgs, y, height } = props; + const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); - const container = useResizeObserver({ - // @ts-expect-error wrong types - ref: containerRef, - }); + if (isLoading) { + return ( +
+
+
+ ); + } return ( -
+
Page cover {imgs.dark && ( @@ -64,8 +64,11 @@ export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) { alt="Page cover" className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} style={{ - aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, - objectPosition: `50% ${getTop(container, y, imgs.dark)}`, + aspectRatio: height + ? undefined + : `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, + objectPosition: `50% ${objectPositionY}%`, + height, // if no height is passed, no height will be set. }} /> )} diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts new file mode 100644 index 0000000000..d7e7c987de --- /dev/null +++ b/packages/gitbook/src/components/PageBody/coverHeight.ts @@ -0,0 +1,26 @@ +import type { RevisionPageDocumentCover } from '@gitbook/api'; + +export const DEFAULT_COVER_HEIGHT = 240; +export const MIN_COVER_HEIGHT = 10; +export const MAX_COVER_HEIGHT = 700; + +// Normalize and clamp the cover height between the minimum and maximum heights +function clampCoverHeight(height: number | null | undefined): number { + if (typeof height !== 'number' || Number.isNaN(height)) { + return DEFAULT_COVER_HEIGHT; + } + + return Math.min(MAX_COVER_HEIGHT, Math.max(MIN_COVER_HEIGHT, height)); +} + +// 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. +export function getCoverHeight( + cover: RevisionPageDocumentCover | null | undefined +): number | undefined { + // Cover (and thus height) is not defined + if (!cover || !cover.height) { + return undefined; + } + + return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT); +} diff --git a/packages/gitbook/src/components/PageBody/index.ts b/packages/gitbook/src/components/PageBody/index.ts index 651c1bdb00..74b91d6169 100644 --- a/packages/gitbook/src/components/PageBody/index.ts +++ b/packages/gitbook/src/components/PageBody/index.ts @@ -1,2 +1,3 @@ export * from './PageBody'; export * from './PageCover'; +export * from './useCoverPosition'; diff --git a/packages/gitbook/src/components/PageBody/useCoverPosition.ts b/packages/gitbook/src/components/PageBody/useCoverPosition.ts new file mode 100644 index 0000000000..7d749f48b0 --- /dev/null +++ b/packages/gitbook/src/components/PageBody/useCoverPosition.ts @@ -0,0 +1,109 @@ +'use client'; +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useResizeObserver } from 'usehooks-ts'; + +interface ImageSize { + width: number; + height: number; +} + +interface ImageAttributes { + src: string; + srcSet?: string; + sizes?: string; + width?: number; + height?: number; + size?: ImageSize; +} + +interface Images { + light: ImageAttributes; + dark?: ImageAttributes; +} + +/** + * Hook to calculate the object position Y percentage for a cover image + * based on the y offset, image dimensions, and container dimensions. + */ +export function useCoverPosition(imgs: Images, y: number) { + const containerRef = useRef(null); + const [loadedDimensions, setLoadedDimensions] = useState(null); + const [isLoading, setIsLoading] = useState(!imgs.light.size && !imgs.dark?.size); + + const container = useResizeObserver({ + // @ts-expect-error wrong types + ref: containerRef, + }); + + // Load original image dimensions if not provided in `imgs` + useLayoutEffect(() => { + // Check if we have dimensions from either light or dark image + const hasDimensions = imgs.light.size || imgs.dark?.size; + + if (hasDimensions) { + return; // Already have dimensions + } + + setIsLoading(true); + + // Load the original image (using src, not srcSet) to get true dimensions + // Use dark image if available, otherwise fall back to light + const imageToLoad = imgs.dark || imgs.light; + const img = new Image(); + img.onload = () => { + setLoadedDimensions({ + width: img.naturalWidth, + height: img.naturalHeight, + }); + setIsLoading(false); + }; + img.onerror = () => { + // If image fails to load, use a fallback + setIsLoading(false); + }; + img.src = imageToLoad.src; + }, [imgs.light, imgs.dark]); + + // Use provided dimensions or fall back to loaded dimensions + // Check light first, then dark, then loaded dimensions + const imageDimensions = imgs.light.size ?? imgs.dark?.size ?? loadedDimensions; + + // Calculate ratio and dimensions similar to useCoverPosition hook + const ratio = + imageDimensions && container.height && container.width + ? Math.max( + container.width / imageDimensions.width, + container.height / imageDimensions.height + ) + : 1; + const safeRatio = ratio || 1; + + const scaledHeight = + imageDimensions && container.height ? imageDimensions.height * safeRatio : null; + const maxOffset = + scaledHeight && container.height + ? Math.max(0, (scaledHeight - container.height) / 2 / safeRatio) + : 0; + + // Parse the position between the allowed min/max + const objectPositionY = useMemo(() => { + if (!container.height || !imageDimensions) { + return 50; + } + + const scaled = imageDimensions.height * safeRatio; + if (scaled <= container.height || maxOffset === 0) { + return 50; + } + + const clampedOffset = Math.max(-maxOffset, Math.min(maxOffset, y)); + const relative = (maxOffset - clampedOffset) / (2 * maxOffset); + return relative * 100; + }, [container.height, imageDimensions, maxOffset, safeRatio, y]); + + return { + containerRef, + objectPositionY, + isLoading: !imageDimensions || isLoading, + }; +}