From 2c773b938ea5f4550a4bbaa8de4f3c6d68264c89 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 31 Oct 2025 16:50:48 +0100 Subject: [PATCH 1/4] Support custom cover heights --- packages/gitbook/e2e/internal.spec.ts | 16 ++- packages/gitbook/e2e/util.ts | 7 ++ .../src/components/PageBody/PageCover.tsx | 21 +++- .../components/PageBody/PageCoverImage.tsx | 48 ++++---- .../components/PageBody/coverDimensions.ts | 75 ++++++++++++ .../src/components/PageBody/coverHeight.ts | 25 ++++ .../gitbook/src/components/PageBody/index.ts | 1 + .../components/PageBody/useCoverPosition.ts | 109 ++++++++++++++++++ 8 files changed, 269 insertions(+), 33 deletions(-) create mode 100644 packages/gitbook/src/components/PageBody/coverDimensions.ts create mode 100644 packages/gitbook/src/components/PageBody/coverHeight.ts create mode 100644 packages/gitbook/src/components/PageBody/useCoverPosition.ts 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..a6a35b89e4 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,23 +23,36 @@ export async function PageCover(props: { context: GitBookSiteContext; }) { const { as, page, cover, context } = props; + const height = getCoverHeight(cover); + + if (!height) { + return null; + } + const [resolved, resolvedDark] = await Promise.all([ cover.ref ? resolveContentRef(cover.ref, context) : null, cover.refDark ? resolveContentRef(cover.refDark, context) : null, ]); + // Calculate sizes based on cover type and page layout + // Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages + // Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) + const isWidePage = page.layout.width === 'wide'; + const maxWidth = as === 'full' ? 1920 : isWidePage ? 1536 : 768; + const sizes = [ - // Cover takes the full width on mobile/table + // Cover takes the full width on mobile { media: '(max-width: 768px)', width: 768, }, + // Tablet sizes { media: '(max-width: 1024px)', width: 1024, }, - // Maximum size of the cover - { width: 1248 }, + // Maximum size based on cover type and page layout + { width: maxWidth }, ]; const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => { @@ -108,6 +122,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..949df9a7d8 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,8 +1,8 @@ 'use client'; import { tcls } from '@/lib/tailwind'; -import { useRef } from 'react'; -import { useResizeObserver } from 'usehooks-ts'; import type { ImageSize } from '../utils'; +import { getRecommendedCoverDimensions } from './coverDimensions'; +import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { src: string; @@ -18,31 +18,25 @@ interface Images { dark?: ImageAttributes; } -const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; +export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; height: number }) { + const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); -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`; -} - -export function PageCoverImage({ imgs, y }: { imgs: Images; y: number }) { - const containerRef = useRef(null); + // Calculate the recommended aspect ratio for this height + // This maintains the 4:1 ratio, allowing images to scale proportionally + // and adapt their height when container width doesn't match the ideal ratio + const recommendedDimensions = getRecommendedCoverDimensions(height); + const aspectRatio = recommendedDimensions.width / recommendedDimensions.height; - const container = useResizeObserver({ - // @ts-expect-error wrong types - ref: containerRef, - }); + if (isLoading) { + return ( +
+
+
+ ); + } return ( -
+
Page cover {imgs.dark && ( @@ -64,8 +58,8 @@ 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: `${aspectRatio}`, + objectPosition: `50% ${objectPositionY}%`, }} /> )} diff --git a/packages/gitbook/src/components/PageBody/coverDimensions.ts b/packages/gitbook/src/components/PageBody/coverDimensions.ts new file mode 100644 index 0000000000..cca99f533f --- /dev/null +++ b/packages/gitbook/src/components/PageBody/coverDimensions.ts @@ -0,0 +1,75 @@ +/** + * Calculates the ideal cover image dimensions based on the cover type, + * page layout, and viewport constraints. + * + * The dimensions are optimized for: + * - Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages + * - Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) + * - Responsive sizes: 768px (mobile), 1024px (tablet), up to 1920px+ (large desktop) + * + * The recommended aspect ratio is optimized to work well across all these scenarios. + */ + +export const DEFAULT_COVER_HEIGHT = 240; + +/** + * Calculate recommended cover image dimensions based on actual rendering widths. + * + * Analysis of actual rendering widths: + * - Hero, regular page: max-w-3xl = 768px + * - Hero, wide page: max-w-screen-2xl = 1536px + * - Full cover (mobile): ~768px (full viewport minus padding) + * - Full cover (tablet): ~1024px (full viewport minus padding) + * - Full cover (desktop): Can be 768px (hero regular) to ~1920px (full wide) on large screens + * + * The recommended aspect ratio (4:1) is a standard format that works well for: + * - Default height of 240px → 960px width (close to tablet size of 1024px) + * - Works well when cropped to 768px (hero regular), 1024px (tablet), 1536px (hero wide), and 1920px (full wide) + * - With `object-cover`, images maintain their natural aspect ratio while filling the container, + * so the 4:1 ratio provides good coverage across all scenarios + * + * This ratio ensures images look good across all scenarios (hero/full, regular/wide, all viewports) + * while maintaining good image quality for responsive srcSet generation and being easy to remember. + * + * @param height - The cover height in pixels (default: 240) + * @returns Recommended width and height for the cover image + */ +export function getRecommendedCoverDimensions(height: number = DEFAULT_COVER_HEIGHT): { + width: number; + height: number; +} { + // Standard 4:1 aspect ratio - a common and easy-to-work-with format + // At 240px height: 960px width + // This ratio works well for: + // - Hero covers on regular pages (768px width) - image will scale to cover + // - Hero covers on wide pages (1536px width) - image will scale to cover + // - Full covers across all breakpoints (768px - 1920px+) - image will scale proportionally + // + // Since we use `object-cover`, the image will scale to fill the container while maintaining + // its aspect ratio, so the 4:1 ratio provides excellent coverage across all scenarios. + // + // Examples for different heights: + // - 240px height → 960px width (recommended for default) + // - 400px height → 1600px width (recommended for taller covers) + // - 500px height → 2000px width (recommended for very tall covers) + const aspectRatio = 4; + + return { + width: Math.round(height * aspectRatio), + height, + }; +} + +/** + * Get the maximum cover width based on cover type and page layout. + * Used for determining the upper bound of image dimensions. + */ +export function getMaxCoverWidth(coverType: 'hero' | 'full', isWidePage: boolean): number { + if (coverType === 'hero') { + // Hero covers: max-w-3xl (768px) or max-w-screen-2xl (1536px) + return isWidePage ? 1536 : 768; + } + // Full covers can expand to viewport width, typically up to 1920px on large screens + // Accounting for some margins, we use 1920px as the maximum + return 1920; +} diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts new file mode 100644 index 0000000000..e4f227770e --- /dev/null +++ b/packages/gitbook/src/components/PageBody/coverHeight.ts @@ -0,0 +1,25 @@ +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)); +} + +export function getCoverHeight( + cover: RevisionPageDocumentCover | null | undefined +): number | undefined { + // Cover (and thus height) is not defined + if (!cover) { + 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, + }; +} From 9cd03597a9aa921ba96af6c0ea25140f741b6da5 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Fri, 31 Oct 2025 20:28:24 +0100 Subject: [PATCH 2/4] Only allow resizing on 'full', not 'hero' covers --- .../src/components/PageBody/PageCover.tsx | 23 +++--- .../components/PageBody/PageCoverImage.tsx | 30 +++++--- .../components/PageBody/coverDimensions.ts | 75 ------------------- .../src/components/PageBody/coverHeight.ts | 7 +- 4 files changed, 33 insertions(+), 102 deletions(-) delete mode 100644 packages/gitbook/src/components/PageBody/coverDimensions.ts diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index a6a35b89e4..6ed0dbefbf 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -22,8 +22,9 @@ export async function PageCover(props: { cover: RevisionPageDocumentCover; context: GitBookSiteContext; }) { - const { as, page, cover, context } = props; - const height = getCoverHeight(cover); + const { as: coverType, page, cover, context } = props; + + const height = getCoverHeight(cover, coverType); if (!height) { return null; @@ -34,25 +35,18 @@ export async function PageCover(props: { cover.refDark ? resolveContentRef(cover.refDark, context) : null, ]); - // Calculate sizes based on cover type and page layout - // Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages - // Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) - const isWidePage = page.layout.width === 'wide'; - const maxWidth = as === 'full' ? 1920 : isWidePage ? 1536 : 768; - const sizes = [ - // Cover takes the full width on mobile + // Cover takes the full width on mobile/table { media: '(max-width: 768px)', width: 768, }, - // Tablet sizes { media: '(max-width: 1024px)', width: 1024, }, - // Maximum size based on cover type and page layout - { width: maxWidth }, + // Maximum size of the cover + { width: 1248 }, ]; const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => { @@ -91,12 +85,12 @@ export async function PageCover(props: { return (
); diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 949df9a7d8..5b7416cb3e 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,7 +1,6 @@ 'use client'; import { tcls } from '@/lib/tailwind'; import type { ImageSize } from '../utils'; -import { getRecommendedCoverDimensions } from './coverDimensions'; import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { @@ -18,14 +17,15 @@ interface Images { dark?: ImageAttributes; } -export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; height: number }) { - const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); +const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; - // Calculate the recommended aspect ratio for this height - // This maintains the 4:1 ratio, allowing images to scale proportionally - // and adapt their height when container width doesn't match the ideal ratio - const recommendedDimensions = getRecommendedCoverDimensions(height); - const aspectRatio = recommendedDimensions.width / recommendedDimensions.height; +export function PageCoverImage({ + imgs, + y, + height, + coverType, +}: { imgs: Images; y: number; height: number; coverType: 'hero' | 'full' }) { + const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); if (isLoading) { return ( @@ -36,17 +36,23 @@ export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; h } return ( -
+
Page cover {imgs.dark && ( @@ -58,7 +64,7 @@ export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; h alt="Page cover" className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} style={{ - aspectRatio: `${aspectRatio}`, + aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, objectPosition: `50% ${objectPositionY}%`, }} /> diff --git a/packages/gitbook/src/components/PageBody/coverDimensions.ts b/packages/gitbook/src/components/PageBody/coverDimensions.ts deleted file mode 100644 index cca99f533f..0000000000 --- a/packages/gitbook/src/components/PageBody/coverDimensions.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Calculates the ideal cover image dimensions based on the cover type, - * page layout, and viewport constraints. - * - * The dimensions are optimized for: - * - Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages - * - Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) - * - Responsive sizes: 768px (mobile), 1024px (tablet), up to 1920px+ (large desktop) - * - * The recommended aspect ratio is optimized to work well across all these scenarios. - */ - -export const DEFAULT_COVER_HEIGHT = 240; - -/** - * Calculate recommended cover image dimensions based on actual rendering widths. - * - * Analysis of actual rendering widths: - * - Hero, regular page: max-w-3xl = 768px - * - Hero, wide page: max-w-screen-2xl = 1536px - * - Full cover (mobile): ~768px (full viewport minus padding) - * - Full cover (tablet): ~1024px (full viewport minus padding) - * - Full cover (desktop): Can be 768px (hero regular) to ~1920px (full wide) on large screens - * - * The recommended aspect ratio (4:1) is a standard format that works well for: - * - Default height of 240px → 960px width (close to tablet size of 1024px) - * - Works well when cropped to 768px (hero regular), 1024px (tablet), 1536px (hero wide), and 1920px (full wide) - * - With `object-cover`, images maintain their natural aspect ratio while filling the container, - * so the 4:1 ratio provides good coverage across all scenarios - * - * This ratio ensures images look good across all scenarios (hero/full, regular/wide, all viewports) - * while maintaining good image quality for responsive srcSet generation and being easy to remember. - * - * @param height - The cover height in pixels (default: 240) - * @returns Recommended width and height for the cover image - */ -export function getRecommendedCoverDimensions(height: number = DEFAULT_COVER_HEIGHT): { - width: number; - height: number; -} { - // Standard 4:1 aspect ratio - a common and easy-to-work-with format - // At 240px height: 960px width - // This ratio works well for: - // - Hero covers on regular pages (768px width) - image will scale to cover - // - Hero covers on wide pages (1536px width) - image will scale to cover - // - Full covers across all breakpoints (768px - 1920px+) - image will scale proportionally - // - // Since we use `object-cover`, the image will scale to fill the container while maintaining - // its aspect ratio, so the 4:1 ratio provides excellent coverage across all scenarios. - // - // Examples for different heights: - // - 240px height → 960px width (recommended for default) - // - 400px height → 1600px width (recommended for taller covers) - // - 500px height → 2000px width (recommended for very tall covers) - const aspectRatio = 4; - - return { - width: Math.round(height * aspectRatio), - height, - }; -} - -/** - * Get the maximum cover width based on cover type and page layout. - * Used for determining the upper bound of image dimensions. - */ -export function getMaxCoverWidth(coverType: 'hero' | 'full', isWidePage: boolean): number { - if (coverType === 'hero') { - // Hero covers: max-w-3xl (768px) or max-w-screen-2xl (1536px) - return isWidePage ? 1536 : 768; - } - // Full covers can expand to viewport width, typically up to 1920px on large screens - // Accounting for some margins, we use 1920px as the maximum - return 1920; -} diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts index e4f227770e..3b96b9b8cf 100644 --- a/packages/gitbook/src/components/PageBody/coverHeight.ts +++ b/packages/gitbook/src/components/PageBody/coverHeight.ts @@ -14,12 +14,17 @@ function clampCoverHeight(height: number | null | undefined): number { } export function getCoverHeight( - cover: RevisionPageDocumentCover | null | undefined + cover: RevisionPageDocumentCover | null | undefined, + as: 'hero' | 'full' ): number | undefined { // Cover (and thus height) is not defined if (!cover) { return undefined; } + if (as === 'hero') { + return DEFAULT_COVER_HEIGHT; + } + return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT); } From 7af7d461ef192e6d956194b19aab811f0784878c Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Mon, 3 Nov 2025 11:15:29 +0100 Subject: [PATCH 3/4] Revert 'only allow resizing for non-hero' --- .../src/components/PageBody/PageCover.tsx | 23 +++--- .../components/PageBody/PageCoverImage.tsx | 30 +++----- .../components/PageBody/coverDimensions.ts | 75 +++++++++++++++++++ .../src/components/PageBody/coverHeight.ts | 7 +- 4 files changed, 102 insertions(+), 33 deletions(-) create mode 100644 packages/gitbook/src/components/PageBody/coverDimensions.ts diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index 6ed0dbefbf..a6a35b89e4 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -22,9 +22,8 @@ export async function PageCover(props: { cover: RevisionPageDocumentCover; context: GitBookSiteContext; }) { - const { as: coverType, page, cover, context } = props; - - const height = getCoverHeight(cover, coverType); + const { as, page, cover, context } = props; + const height = getCoverHeight(cover); if (!height) { return null; @@ -35,18 +34,25 @@ export async function PageCover(props: { cover.refDark ? resolveContentRef(cover.refDark, context) : null, ]); + // Calculate sizes based on cover type and page layout + // Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages + // Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) + const isWidePage = page.layout.width === 'wide'; + const maxWidth = as === 'full' ? 1920 : isWidePage ? 1536 : 768; + const sizes = [ - // Cover takes the full width on mobile/table + // Cover takes the full width on mobile { media: '(max-width: 768px)', width: 768, }, + // Tablet sizes { media: '(max-width: 1024px)', width: 1024, }, - // Maximum size of the cover - { width: 1248 }, + // Maximum size based on cover type and page layout + { width: maxWidth }, ]; const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => { @@ -85,12 +91,12 @@ export async function PageCover(props: { return (
); diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 5b7416cb3e..949df9a7d8 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,6 +1,7 @@ 'use client'; import { tcls } from '@/lib/tailwind'; import type { ImageSize } from '../utils'; +import { getRecommendedCoverDimensions } from './coverDimensions'; import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { @@ -17,16 +18,15 @@ interface Images { dark?: ImageAttributes; } -const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; - -export function PageCoverImage({ - imgs, - y, - height, - coverType, -}: { imgs: Images; y: number; height: number; coverType: 'hero' | 'full' }) { +export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; height: number }) { const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); + // Calculate the recommended aspect ratio for this height + // This maintains the 4:1 ratio, allowing images to scale proportionally + // and adapt their height when container width doesn't match the ideal ratio + const recommendedDimensions = getRecommendedCoverDimensions(height); + const aspectRatio = recommendedDimensions.width / recommendedDimensions.height; + if (isLoading) { return (
@@ -36,23 +36,17 @@ export function PageCoverImage({ } return ( -
+
Page cover {imgs.dark && ( @@ -64,7 +58,7 @@ export function PageCoverImage({ alt="Page cover" className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} style={{ - aspectRatio: `${PAGE_COVER_SIZE.width}/${PAGE_COVER_SIZE.height}`, + aspectRatio: `${aspectRatio}`, objectPosition: `50% ${objectPositionY}%`, }} /> diff --git a/packages/gitbook/src/components/PageBody/coverDimensions.ts b/packages/gitbook/src/components/PageBody/coverDimensions.ts new file mode 100644 index 0000000000..cca99f533f --- /dev/null +++ b/packages/gitbook/src/components/PageBody/coverDimensions.ts @@ -0,0 +1,75 @@ +/** + * Calculates the ideal cover image dimensions based on the cover type, + * page layout, and viewport constraints. + * + * The dimensions are optimized for: + * - Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages + * - Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) + * - Responsive sizes: 768px (mobile), 1024px (tablet), up to 1920px+ (large desktop) + * + * The recommended aspect ratio is optimized to work well across all these scenarios. + */ + +export const DEFAULT_COVER_HEIGHT = 240; + +/** + * Calculate recommended cover image dimensions based on actual rendering widths. + * + * Analysis of actual rendering widths: + * - Hero, regular page: max-w-3xl = 768px + * - Hero, wide page: max-w-screen-2xl = 1536px + * - Full cover (mobile): ~768px (full viewport minus padding) + * - Full cover (tablet): ~1024px (full viewport minus padding) + * - Full cover (desktop): Can be 768px (hero regular) to ~1920px (full wide) on large screens + * + * The recommended aspect ratio (4:1) is a standard format that works well for: + * - Default height of 240px → 960px width (close to tablet size of 1024px) + * - Works well when cropped to 768px (hero regular), 1024px (tablet), 1536px (hero wide), and 1920px (full wide) + * - With `object-cover`, images maintain their natural aspect ratio while filling the container, + * so the 4:1 ratio provides good coverage across all scenarios + * + * This ratio ensures images look good across all scenarios (hero/full, regular/wide, all viewports) + * while maintaining good image quality for responsive srcSet generation and being easy to remember. + * + * @param height - The cover height in pixels (default: 240) + * @returns Recommended width and height for the cover image + */ +export function getRecommendedCoverDimensions(height: number = DEFAULT_COVER_HEIGHT): { + width: number; + height: number; +} { + // Standard 4:1 aspect ratio - a common and easy-to-work-with format + // At 240px height: 960px width + // This ratio works well for: + // - Hero covers on regular pages (768px width) - image will scale to cover + // - Hero covers on wide pages (1536px width) - image will scale to cover + // - Full covers across all breakpoints (768px - 1920px+) - image will scale proportionally + // + // Since we use `object-cover`, the image will scale to fill the container while maintaining + // its aspect ratio, so the 4:1 ratio provides excellent coverage across all scenarios. + // + // Examples for different heights: + // - 240px height → 960px width (recommended for default) + // - 400px height → 1600px width (recommended for taller covers) + // - 500px height → 2000px width (recommended for very tall covers) + const aspectRatio = 4; + + return { + width: Math.round(height * aspectRatio), + height, + }; +} + +/** + * Get the maximum cover width based on cover type and page layout. + * Used for determining the upper bound of image dimensions. + */ +export function getMaxCoverWidth(coverType: 'hero' | 'full', isWidePage: boolean): number { + if (coverType === 'hero') { + // Hero covers: max-w-3xl (768px) or max-w-screen-2xl (1536px) + return isWidePage ? 1536 : 768; + } + // Full covers can expand to viewport width, typically up to 1920px on large screens + // Accounting for some margins, we use 1920px as the maximum + return 1920; +} diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts index 3b96b9b8cf..e4f227770e 100644 --- a/packages/gitbook/src/components/PageBody/coverHeight.ts +++ b/packages/gitbook/src/components/PageBody/coverHeight.ts @@ -14,17 +14,12 @@ function clampCoverHeight(height: number | null | undefined): number { } export function getCoverHeight( - cover: RevisionPageDocumentCover | null | undefined, - as: 'hero' | 'full' + cover: RevisionPageDocumentCover | null | undefined ): number | undefined { // Cover (and thus height) is not defined if (!cover) { return undefined; } - if (as === 'hero') { - return DEFAULT_COVER_HEIGHT; - } - return clampCoverHeight((cover as RevisionPageDocumentCover).height ?? DEFAULT_COVER_HEIGHT); } From 522ce0d7b8343fac49a3a5d0861824f9857778e3 Mon Sep 17 00:00:00 2001 From: Viktor Renkema Date: Mon, 3 Nov 2025 11:27:34 +0100 Subject: [PATCH 4/4] Preserve existing sizing when no `height` is set --- .../src/components/PageBody/PageCover.tsx | 17 +---- .../components/PageBody/PageCoverImage.tsx | 29 ++++--- .../components/PageBody/coverDimensions.ts | 75 ------------------- .../src/components/PageBody/coverHeight.ts | 3 +- 4 files changed, 24 insertions(+), 100 deletions(-) delete mode 100644 packages/gitbook/src/components/PageBody/coverDimensions.ts diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index a6a35b89e4..75871ac15f 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -25,34 +25,23 @@ export async function PageCover(props: { const { as, page, cover, context } = props; const height = getCoverHeight(cover); - if (!height) { - return null; - } - const [resolved, resolvedDark] = await Promise.all([ cover.ref ? resolveContentRef(cover.ref, context) : null, cover.refDark ? resolveContentRef(cover.refDark, context) : null, ]); - // Calculate sizes based on cover type and page layout - // Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages - // Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) - const isWidePage = page.layout.width === 'wide'; - const maxWidth = as === 'full' ? 1920 : isWidePage ? 1536 : 768; - const sizes = [ - // Cover takes the full width on mobile + // Cover takes the full width on mobile/table { media: '(max-width: 768px)', width: 768, }, - // Tablet sizes { media: '(max-width: 1024px)', width: 1024, }, - // Maximum size based on cover type and page layout - { width: maxWidth }, + // Maximum size of the cover + { width: 1248 }, ]; const getImage = async (resolved: ResolvedContentRef | null, returnNull = false) => { diff --git a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx index 949df9a7d8..eb2190b744 100644 --- a/packages/gitbook/src/components/PageBody/PageCoverImage.tsx +++ b/packages/gitbook/src/components/PageBody/PageCoverImage.tsx @@ -1,7 +1,6 @@ 'use client'; import { tcls } from '@/lib/tailwind'; import type { ImageSize } from '../utils'; -import { getRecommendedCoverDimensions } from './coverDimensions'; import { useCoverPosition } from './useCoverPosition'; interface ImageAttributes { @@ -18,14 +17,18 @@ interface Images { dark?: ImageAttributes; } -export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; height: number }) { - const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); +const PAGE_COVER_SIZE: ImageSize = { width: 1990, height: 480 }; + +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; +} - // Calculate the recommended aspect ratio for this height - // This maintains the 4:1 ratio, allowing images to scale proportionally - // and adapt their height when container width doesn't match the ideal ratio - const recommendedDimensions = getRecommendedCoverDimensions(height); - const aspectRatio = recommendedDimensions.width / recommendedDimensions.height; +export function PageCoverImage(props: PageCoverImageProps) { + const { imgs, y, height } = props; + const { containerRef, objectPositionY, isLoading } = useCoverPosition(imgs, y); if (isLoading) { return ( @@ -45,8 +48,11 @@ export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; h alt="Page cover" className={tcls('w-full', 'object-cover', imgs.dark ? 'dark:hidden' : '')} style={{ - aspectRatio: `${aspectRatio}`, + 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. }} /> {imgs.dark && ( @@ -58,8 +64,11 @@ export function PageCoverImage({ imgs, y, height }: { imgs: Images; y: number; h alt="Page cover" className={tcls('w-full', 'object-cover', 'dark:inline', 'hidden')} style={{ - aspectRatio: `${aspectRatio}`, + 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/coverDimensions.ts b/packages/gitbook/src/components/PageBody/coverDimensions.ts deleted file mode 100644 index cca99f533f..0000000000 --- a/packages/gitbook/src/components/PageBody/coverDimensions.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Calculates the ideal cover image dimensions based on the cover type, - * page layout, and viewport constraints. - * - * The dimensions are optimized for: - * - Hero covers: max-w-3xl (768px) on regular pages, max-w-screen-2xl (1536px) on wide pages - * - Full covers: Can expand to full viewport width with negative margins (up to ~1920px+ on large screens) - * - Responsive sizes: 768px (mobile), 1024px (tablet), up to 1920px+ (large desktop) - * - * The recommended aspect ratio is optimized to work well across all these scenarios. - */ - -export const DEFAULT_COVER_HEIGHT = 240; - -/** - * Calculate recommended cover image dimensions based on actual rendering widths. - * - * Analysis of actual rendering widths: - * - Hero, regular page: max-w-3xl = 768px - * - Hero, wide page: max-w-screen-2xl = 1536px - * - Full cover (mobile): ~768px (full viewport minus padding) - * - Full cover (tablet): ~1024px (full viewport minus padding) - * - Full cover (desktop): Can be 768px (hero regular) to ~1920px (full wide) on large screens - * - * The recommended aspect ratio (4:1) is a standard format that works well for: - * - Default height of 240px → 960px width (close to tablet size of 1024px) - * - Works well when cropped to 768px (hero regular), 1024px (tablet), 1536px (hero wide), and 1920px (full wide) - * - With `object-cover`, images maintain their natural aspect ratio while filling the container, - * so the 4:1 ratio provides good coverage across all scenarios - * - * This ratio ensures images look good across all scenarios (hero/full, regular/wide, all viewports) - * while maintaining good image quality for responsive srcSet generation and being easy to remember. - * - * @param height - The cover height in pixels (default: 240) - * @returns Recommended width and height for the cover image - */ -export function getRecommendedCoverDimensions(height: number = DEFAULT_COVER_HEIGHT): { - width: number; - height: number; -} { - // Standard 4:1 aspect ratio - a common and easy-to-work-with format - // At 240px height: 960px width - // This ratio works well for: - // - Hero covers on regular pages (768px width) - image will scale to cover - // - Hero covers on wide pages (1536px width) - image will scale to cover - // - Full covers across all breakpoints (768px - 1920px+) - image will scale proportionally - // - // Since we use `object-cover`, the image will scale to fill the container while maintaining - // its aspect ratio, so the 4:1 ratio provides excellent coverage across all scenarios. - // - // Examples for different heights: - // - 240px height → 960px width (recommended for default) - // - 400px height → 1600px width (recommended for taller covers) - // - 500px height → 2000px width (recommended for very tall covers) - const aspectRatio = 4; - - return { - width: Math.round(height * aspectRatio), - height, - }; -} - -/** - * Get the maximum cover width based on cover type and page layout. - * Used for determining the upper bound of image dimensions. - */ -export function getMaxCoverWidth(coverType: 'hero' | 'full', isWidePage: boolean): number { - if (coverType === 'hero') { - // Hero covers: max-w-3xl (768px) or max-w-screen-2xl (1536px) - return isWidePage ? 1536 : 768; - } - // Full covers can expand to viewport width, typically up to 1920px on large screens - // Accounting for some margins, we use 1920px as the maximum - return 1920; -} diff --git a/packages/gitbook/src/components/PageBody/coverHeight.ts b/packages/gitbook/src/components/PageBody/coverHeight.ts index e4f227770e..d7e7c987de 100644 --- a/packages/gitbook/src/components/PageBody/coverHeight.ts +++ b/packages/gitbook/src/components/PageBody/coverHeight.ts @@ -13,11 +13,12 @@ function clampCoverHeight(height: number | null | undefined): number { 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) { + if (!cover || !cover.height) { return undefined; }