Fix loading of images in PDF export (#3958)

Co-authored-by: Samy Pessé <samypesse@gmail.com>
This commit is contained in:
Greg Bergé
2026-01-29 15:55:42 +01:00
committed by GitHub
parent 844a0a8ea7
commit 551d30cfa4
22 changed files with 205 additions and 65 deletions
@@ -31,7 +31,7 @@ export async function Drawing(props: BlockProps<DocumentBlockDrawing>) {
alt="Drawing"
sizes={imageBlockSizes}
zoom
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
</Caption>
);
@@ -60,7 +60,7 @@ export async function Embed(props: BlockProps<gitbookAPI.DocumentBlockEmbed>) {
sources={{ light: { src: embed.icon } }}
sizes={[{ width: 20 }]}
resize={context.contentContext.imageResizer}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
) : null
}
@@ -115,7 +115,7 @@ async function ImageBlock(props: {
}
: null,
}}
loading={isEstimatedOffscreen ? 'lazy' : 'eager'}
loading={isEstimatedOffscreen && context.mode !== 'print' ? 'lazy' : 'eager'}
zoom
inlineStyle={{
maxWidth: '100%',
@@ -51,7 +51,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
}
: null,
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
style={[size === 'line' ? ['max-h-lh', 'h-lh', 'w-auto'] : null]}
inline
zoom={!isInLink}
@@ -120,7 +120,7 @@ export async function RecordCard(
: ['h-auto', 'aspect-video'],
objectFits
)}
loading={isOffscreen ? 'lazy' : 'eager'}
loading={isOffscreen && context.mode !== 'print' ? 'lazy' : 'eager'}
/>
) : null}
<div
@@ -230,7 +230,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
},
},
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
) : (
<FileIcon
@@ -410,7 +410,7 @@ export async function RecordColumnValue<Tag extends React.ElementType = 'div'>(
size: image.file?.dimensions,
},
}}
loading="lazy"
loading={context.mode === 'print' ? 'eager' : 'lazy'}
/>
{image.text}
</StyledLink>
@@ -0,0 +1,141 @@
'use client';
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import assertNever from 'assert-never';
import * as React from 'react';
export function ImagesLoadingStatus(props: {
language: TranslationLanguage;
}) {
const { language } = props;
const state = useImagesLoadingState();
return (
<p className="text-right text-slate-500 text-xs">
{(() => {
switch (state.status) {
case 'pending':
return null;
case 'loading':
return tString(
language,
'pdf_images_loading',
`${state.loadedImages}`,
`${state.totalImages}`
);
case 'ready':
return tString(language, 'pdf_images_loaded');
default:
assertNever(state);
}
})()}
</p>
);
}
/**
* Keep track of images loading state.
*/
function useImagesLoadingState() {
const [totalImages, setTotalImages] = React.useState<number | null>(null);
const [loadedImages, setLoadedImages] = React.useState(0);
const attachedImages = React.useRef(new WeakSet<HTMLImageElement>());
const rafRef = React.useRef<number>(0);
const calculateImageStatus = React.useCallback(() => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
rafRef.current = requestAnimationFrame(() => {
const images = Array.from(document.images);
setTotalImages(images.length);
setLoadedImages(images.filter((image) => image.complete).length);
});
}, []);
React.useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const attachListeners = React.useCallback(
(images: HTMLImageElement[]) => {
images.forEach((image) => {
if (image.complete || attachedImages.current.has(image)) {
return;
}
if (image.loading !== 'eager') {
console.warn('An image is not in "eager" mode', image);
}
attachedImages.current.add(image);
image.addEventListener('load', calculateImageStatus);
image.addEventListener('error', calculateImageStatus);
});
},
[calculateImageStatus]
);
React.useEffect(() => {
calculateImageStatus();
attachListeners(Array.from(document.images));
let raf: number;
const observer = new MutationObserver((mutationsList) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const newImages: HTMLImageElement[] = [];
let shouldRecalculate = false;
for (const mutation of mutationsList) {
if (mutation.type !== 'childList') {
continue;
}
if (mutation.removedNodes.length > 0) {
// Images may have been removed; recalculate counts.
shouldRecalculate = true;
}
mutation.addedNodes.forEach((node) => {
if (node instanceof HTMLImageElement) {
newImages.push(node);
shouldRecalculate = true;
} else if (node instanceof HTMLElement) {
const nestedImages = Array.from(
node.getElementsByTagName('img')
) as HTMLImageElement[];
if (nestedImages.length > 0) {
newImages.push(...nestedImages);
shouldRecalculate = true;
}
}
});
}
if (newImages.length > 0) {
attachListeners(newImages);
}
if (shouldRecalculate) {
calculateImageStatus();
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
Array.from(document.images).forEach((image) => {
image.removeEventListener('load', calculateImageStatus);
image.removeEventListener('error', calculateImageStatus);
});
};
}, [attachListeners, calculateImageStatus]);
if (totalImages === null) {
return { status: 'pending' } as const;
}
return {
status: totalImages === loadedImages ? 'ready' : 'loading',
totalImages,
loadedImages,
} as const;
}
+11 -41
View File
@@ -24,11 +24,12 @@ import { tcls } from '@/lib/tailwind';
import { defaultCustomization } from '@/lib/utils';
import { type PDFSearchParams, getPDFSearchParams } from './urls';
import { PDFPrintControls } from './PDFPrintControls';
import { PageControlButtons } from './PageControlButtons';
import { PrintButton } from './PrintButton';
import './pdf.css';
import { sanitizeGitBookAppURL } from '@/lib/app';
import { getPageDocument } from '@/lib/data';
import { ImagesLoadingStatus } from './ImagesLoadingStatus';
const DEFAULT_LIMIT = 100;
@@ -118,28 +119,9 @@ export async function PDFPage(props: {
</div>
) : null}
<div className={tcls('fixed', 'right-12', 'top-12', 'print:hidden', 'z-50')}>
<PrintButton
title={tString(language, 'pdf_print')}
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-tint',
'hover:text-primary',
'p-4',
'rounded-full',
'bg-white',
'shadow-xs',
'hover:shadow-md',
'border-slate-300',
'border'
)}
>
<Icon icon="print" className={tcls('size-6')} />
</PrintButton>
<div className="fixed top-12 right-12 z-50 flex flex-col items-end gap-2 print:hidden">
<PDFPrintControls language={language} />
<ImagesLoadingStatus language={language} />
</div>
<PageControlButtons
@@ -187,10 +169,8 @@ async function PDFSpaceIntro(props: {
return (
<PrintPage isFirst>
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
<h1 className={tcls('text-6xl', 'font-bold')}>
{customization.title ?? space.title}
</h1>
<div className="flex items-center justify-center py-12">
<h1 className="font-bold text-6xl">{customization.title ?? space.title}</h1>
</div>
</PrintPage>
);
@@ -201,18 +181,8 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<div
className={tcls(
'break-before-page',
'mt-10',
'print:mt-0',
'flex',
'items-center',
'justify-center',
'py-12'
)}
>
<h1 className={tcls('text-5xl', 'font-bold')}>{page.title}</h1>
<div className="mt-10 flex break-before-page items-center justify-center py-12 print:mt-0">
<h1 className="font-bold text-5xl">{page.title}</h1>
</div>
</PrintPage>
);
@@ -227,9 +197,9 @@ async function PDFPageDocument(props: {
return (
<PrintPage id={getPagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
<h1 className="font-bold text-4xl">{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
<p className="mt-2 mb-3 decoration-primary/6">{page.description}</p>
) : null}
{document ? (
@@ -0,0 +1,18 @@
import { tString } from '@/intl/translate';
import type { TranslationLanguage } from '@/intl/translations';
import { Icon } from '@gitbook/icons';
import { PrintButton } from './PrintButton';
export function PDFPrintControls(props: { language: TranslationLanguage }) {
const { language } = props;
return (
<PrintButton
title={tString(language, 'pdf_print')}
className="flex items-center justify-center rounded-full border border-slate-300 bg-white p-4 text-sm text-tint shadow-xs hover:text-primary hover:shadow-md"
>
<Icon icon="print" className="size-6" />
</PrintButton>
);
}
@@ -1,19 +1,7 @@
'use client';
import * as React from 'react';
import type { ComponentPropsWithRef } from 'react';
import type { PolymorphicComponentProp } from '@/components/utils/types';
export function PrintButton(props: PolymorphicComponentProp<'button'>) {
const { className, children, ...rest } = props;
const onClick = React.useCallback(() => {
window.print();
}, []);
return (
<button {...rest} data-testid="print-button" onClick={onClick} className={className}>
{children}
</button>
);
export function PrintButton(props: Omit<ComponentPropsWithRef<'button'>, 'onClick'>) {
return <button {...props} data-testid="print-button" onClick={() => window.print()} />;
}
@@ -110,7 +110,7 @@ export function Image(
} & ImageCommonProps
>
) {
const { sources, style, inline = false, alt, ...rest } = props;
const { sources, style, inline = false, alt, loading, ...rest } = props;
return (
<>
@@ -125,6 +125,7 @@ export function Image(
style
)}
alt={sources.light.alt || alt}
loading={loading}
/>
{sources.dark ? (
<ImagePicture
@@ -133,7 +134,7 @@ export function Image(
inline={inline}
// We don't want to preload the dark image, because it's not visible
// TODO: adapt based on the default theme
loading="lazy"
loading={loading === 'eager' ? 'eager' : 'lazy'}
className={tcls(
rest.className,
'hidden',
@@ -79,6 +79,8 @@ export const de = {
pdf_download: 'Als PDF exportieren',
pdf_goback: 'Zurück zum Inhalt',
pdf_print: 'Drucken oder als PDF speichern',
pdf_images_loading: 'Bilder werden geladen (${1}/${2})',
pdf_images_loaded: 'Alle Bilder geladen',
pdf_page_of: '${1} von ${2}',
pdf_mode_only_page: 'Nur diese Seite',
pdf_mode_all: 'Alle Seiten',
@@ -77,6 +77,8 @@ export const en = {
pdf_download: 'Export as PDF',
pdf_goback: 'Go back to content',
pdf_print: 'Print or Save as PDF',
pdf_images_loading: 'Images loading (${1}/${2})',
pdf_images_loaded: 'All images loaded',
pdf_page_of: '${1} of ${2}',
pdf_mode_only_page: 'Only this page',
pdf_mode_all: 'All pages',
@@ -80,6 +80,8 @@ export const es: TranslationLanguage = {
pdf_download: 'Exportar como PDF',
pdf_goback: 'Volver al contenido',
pdf_print: 'Imprimir o Guardar como PDF',
pdf_images_loading: 'Cargando imágenes (${1}/${2})',
pdf_images_loaded: 'Todas las imágenes cargadas',
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Solo esta página',
pdf_mode_all: 'Todas las páginas',
@@ -76,6 +76,8 @@ export const fr = {
pdf_download: 'Exporter en PDF',
pdf_goback: 'Retourner au contenu',
pdf_print: 'Imprimer ou enregistrer en PDF',
pdf_images_loading: 'Chargement des images (${1}/${2})',
pdf_images_loaded: 'Toutes les images sont chargées',
pdf_page_of: '${1} sur ${2}',
pdf_mode_only_page: 'Cette page uniquement',
pdf_mode_all: 'Toutes les pages',
@@ -79,6 +79,8 @@ export const it: TranslationLanguage = {
pdf_download: 'Esporta in PDF',
pdf_goback: 'Torna al contenuto',
pdf_print: 'Stampa o salva come PDF',
pdf_images_loading: 'Caricamento immagini (${1}/${2})',
pdf_images_loaded: 'Tutte le immagini caricate',
pdf_page_of: '${1} di ${2}',
pdf_mode_only_page: 'Solo questa pagina',
pdf_mode_all: 'Tutte le pagine',
@@ -79,6 +79,8 @@ export const ja: TranslationLanguage = {
pdf_download: 'PDFとしてエクスポート',
pdf_goback: 'コンテンツに戻る',
pdf_print: '印刷するかPDFとして保存',
pdf_images_loading: '画像を読み込み中 (${1}/${2})',
pdf_images_loaded: '画像の読み込み完了',
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: 'このページのみ',
pdf_mode_all: '全てのページ',
@@ -79,6 +79,8 @@ export const nl: TranslationLanguage = {
pdf_download: 'Exporteer als PDF',
pdf_goback: 'Ga terug naar inhoud',
pdf_print: 'Print of opslaan als PDF',
pdf_images_loading: 'Afbeeldingen laden (${1}/${2})',
pdf_images_loaded: 'Alle afbeeldingen geladen',
pdf_page_of: '${1} van ${2}',
pdf_mode_only_page: 'Alleen deze pagina',
pdf_mode_all: "Alle pagina's",
@@ -80,6 +80,8 @@ export const no: TranslationLanguage = {
pdf_download: 'Eksporter som PDF',
pdf_goback: 'Gå tilbake til innhold',
pdf_print: 'Skriv ut eller lagre som PDF',
pdf_images_loading: 'Bilder lastes (${1}/${2})',
pdf_images_loaded: 'Alle bilder lastet',
pdf_page_of: '${1} av ${2}',
pdf_mode_only_page: 'Kun denne siden',
pdf_mode_all: 'Alle sider',
@@ -78,6 +78,8 @@ export const pt_br = {
pdf_download: 'Exportar como PDF',
pdf_goback: 'Voltar ao conteúdo',
pdf_print: 'Imprimir ou salvar como PDF',
pdf_images_loading: 'Carregando imagens (${1}/${2})',
pdf_images_loaded: 'Todas as imagens carregadas',
pdf_page_of: '${1} de ${2}',
pdf_mode_only_page: 'Somente esta página',
pdf_mode_all: 'Todas as páginas',
@@ -77,6 +77,8 @@ export const ru = {
pdf_download: 'Экспортировать как PDF',
pdf_goback: 'Вернуться к материалу',
pdf_print: 'Напечатать или сохранить как PDF',
pdf_images_loading: 'Загрузка изображений (${1}/${2})',
pdf_images_loaded: 'Все изображения загружены',
pdf_page_of: '${1} из ${2}',
pdf_mode_only_page: 'Только эта страница',
pdf_mode_all: 'Все страницы',
@@ -77,6 +77,8 @@ export const zh: TranslationLanguage = {
pdf_download: '导出为 PDF',
pdf_goback: '返回内容',
pdf_print: '打印或另存为 PDF',
pdf_images_loading: '图片加载中 (${1}/${2})',
pdf_images_loaded: '图片已全部加载',
pdf_page_of: '${1} / ${2}',
pdf_mode_only_page: '仅本页',
pdf_mode_all: '所有页面',