mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 10:03:31 +00:00
Fix loading of images in PDF export (#3958)
Co-authored-by: Samy Pessé <samypesse@gmail.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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: '所有页面',
|
||||
|
||||
Reference in New Issue
Block a user