Add link to PDF export in the page and improve PDF UI (#73)

* Add buttons to pdf page

* Present as page

* Fix break

* Show active page

* Add description

* Make buttons work

* Handle back link

* Show alert when reaching maximum

* Format
This commit is contained in:
Samy Pessé
2023-12-26 23:14:46 +01:00
committed by GitHub
parent 24f5e73bbe
commit 83f4cb5968
13 changed files with 517 additions and 173 deletions
@@ -1,13 +0,0 @@
'use client';
import * as React from 'react';
export function OpenPrintDialog(props: {}) {
React.useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
window.print();
}
}, []);
return null;
}
@@ -0,0 +1,128 @@
'use client';
import { AlertTriangle } from '@geist-ui/icons';
import React from 'react';
import { useScrollActiveId } from '@/components/hooks';
import { Button } from '@/components/primitives';
import { t, useLanguage } from '@/intl/client';
import { tcls } from '@/lib/tailwind';
import { PDFSearchParams, getPDFParams } from './params';
/**
* Dynamic controls to show active page and to let the user select between modes.
*/
export function PageControlButtons(props: {
pdfParams: PDFSearchParams;
pdfHref: string;
/** Array of the [pageId, divId] */
pageIds: Array<[string, string]>;
/** Total number of pages targetted by the generation */
total: number;
}) {
const { pdfParams, pdfHref, pageIds, total } = props;
const language = useLanguage();
const divIds = React.useMemo(() => {
return pageIds.map((entry) => entry[1]);
}, [pageIds]);
const activeDivId = useScrollActiveId(divIds, {
threshold: 0,
});
const activeIndex = (activeDivId ? divIds.indexOf(activeDivId) : 0) + 1;
const activePageId = pageIds[activeIndex - 1]?.[0];
return (
<>
<div
className={tcls(
'fixed',
'left-12',
'bottom-12',
'flex',
'flex-col',
'gap-2',
'print:hidden',
)}
>
<Button
href={
pdfHref +
'?' +
getPDFParams({ ...pdfParams, page: activePageId, only: true })
}
variant="secondary"
>
{t(language, 'pdf_mode_only_page')}
</Button>
<Button
href={
pdfHref + '?' + getPDFParams({ ...pdfParams, page: undefined, only: false })
}
variant="secondary"
>
{t(language, 'pdf_mode_all')}
</Button>
</div>
<div
className={tcls(
'fixed',
'right-12',
'bottom-12',
'flex',
'flex-col',
'items-end',
'gap-2',
'print:hidden',
)}
>
{total !== pageIds.length ? (
<div
role="banner"
className={tcls(
'flex',
'flex-row',
'items-start',
'mb-5',
'bg-yellow-100',
'border-yellow-400',
'text-yellow-800',
'shadow-sm',
'border',
'rounded-md',
'p-4',
'max-w-sm',
)}
>
<AlertTriangle className={tcls('size-6', 'mr-3', 'mt-1')} />{' '}
{t(language, 'pdf_limit_reached', total, pageIds.length)}
</div>
) : null}
{/* <div className={tcls('flex', 'flex-row')}> */}
<div
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-lg',
'text-dark/6',
'px-6',
'py-2',
'bg-slate-100',
'rounded-full',
'shadow-sm',
'border-slate-300',
'border',
)}
>
{t(language, 'pdf_page_of', activeIndex, pageIds.length)}
</div>
{/* </div> */}
</div>
</>
);
}
@@ -0,0 +1,25 @@
'use client';
import * as React from 'react';
import { PolymorphicComponentProp } from '@/components/utils/types';
export function PrintButton(props: PolymorphicComponentProp<'button'>) {
const { className, children, ...rest } = props;
const onClick = React.useCallback(() => {
window.print();
}, []);
React.useEffect(() => {
if (process.env.NODE_ENV !== 'development') {
onClick();
}
}, [onClick]);
return (
<button {...rest} onClick={onClick} className={className}>
{children}
</button>
);
}
+172 -57
View File
@@ -1,27 +1,37 @@
import { ArrowLeft, Printer } from '@geist-ui/icons';
import { Revision, RevisionPageDocument, RevisionPageGroup, Space } from '@gitbook/api';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import * as React from 'react';
import { DocumentView } from '@/components/DocumentView';
import { getDocument, getSpace, getRevisionPages, ContentPointer } from '@/lib/api';
import { pagePDFContainerId, PageHrefContext } from '@/lib/links';
import { PolymorphicComponentProp } from '@/components/utils/types';
import { getSpaceLanguage } from '@/intl/server';
import { tString } from '@/intl/translate';
import {
getDocument,
getSpace,
getRevisionPages,
ContentPointer,
getSpaceCustomization,
} from '@/lib/api';
import { pagePDFContainerId, PageHrefContext, absoluteHref } from '@/lib/links';
import { resolvePageId } from '@/lib/pages';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { OpenPrintDialog } from './OpenPrintDialog';
import { SpaceParams } from '../../fetch';
import './pdf.css';
import { PageControlButtons } from './PageControlButtons';
import { PDFSearchParams } from './params';
import { PrintButton } from './PrintButton';
import { SpaceParams } from '../../fetch';
export const runtime = 'edge';
interface PDFSearchParams {
/** Page to export. If none is passed, all pages are exported. */
page?: string;
/** If true, only the `page` is exported, and not its descendant */
only?: boolean;
/** Limit the number of pages */
limit?: number;
export async function generateMetadata({ params }: { params: SpaceParams }): Promise<Metadata> {
return {
title: 'Print',
};
}
/**
@@ -39,34 +49,86 @@ export default async function PDFHTMLOutput(props: {
spaceId,
};
const [space, rootPages] = await Promise.all([
const [space, customization, rootPages] = await Promise.all([
getSpace(spaceId),
getSpaceCustomization(spaceId),
getRevisionPages(contentPointer),
]);
const pages = selectPages(rootPages, searchParams).slice(0, searchParams.limit ?? 10);
const language = getSpaceLanguage(customization);
const { pages, total } = selectPages(rootPages, searchParams);
const linksContext: PageHrefContext = {
pdf: pages.map(({ page }) => page.id),
};
const pageIds = pages.map(
({ page }) => [page.id, pagePDFContainerId(page)] as [string, string],
);
return (
<div
className={tcls(
'my-11',
'print:my-0',
'mx-auto',
'max-w-4xl',
'w-full',
'p-12',
'print:p-0',
'shadow-xl',
'print:shadow-none',
'rounded-sm',
'bg-white',
)}
>
<SpaceIntro space={space} />
<>
{searchParams.back !== 'false' ? (
<div className={tcls('fixed', 'left-12', 'top-12', 'print:hidden')}>
<a
title={tString(language, 'pdf_goback')}
href={searchParams.back ?? absoluteHref('')}
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-dark/6',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
'hover:shadow-md',
'border-slate-300',
'border',
)}
>
<ArrowLeft className={tcls('size-6')} />
</a>
</div>
) : null}
<div className={tcls('fixed', 'right-12', 'top-12', 'print:hidden')}>
<PrintButton
title={tString(language, 'pdf_print')}
className={tcls(
'flex',
'flex-row',
'items-center',
'justify-center',
'text-sm',
'text-dark/6',
'hover:text-primary',
'p-4',
'dark:text-light/5',
'rounded-full',
'bg-white',
'shadow-sm',
'hover:shadow-md',
'border-slate-300',
'border',
)}
>
<Printer className={tcls('size-6')} />
</PrintButton>
</div>
<PageControlButtons
pageIds={pageIds}
pdfParams={searchParams}
pdfHref={absoluteHref('~gitbook/pdf')}
total={total}
/>
{searchParams.only ? null : <SpaceIntro space={space} />}
{pages.map(({ page, depth }) =>
page.type === 'group' ? (
<PDFPageGroup key={page.id} space={space} page={page} />
@@ -86,9 +148,7 @@ export default async function PDFHTMLOutput(props: {
</React.Suspense>
),
)}
<OpenPrintDialog />
</div>
</>
);
}
@@ -96,9 +156,11 @@ async function SpaceIntro(props: { space: Space }) {
const { space } = props;
return (
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
<h1 className={tcls('text-6xl', 'font-bold')}>{space.title}</h1>
</div>
<PrintPage isFirst>
<div className={tcls('flex', 'items-center', 'justify-center', 'py-12')}>
<h1 className={tcls('text-6xl', 'font-bold')}>{space.title}</h1>
</div>
</PrintPage>
);
}
@@ -106,19 +168,21 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) {
const { page } = props;
return (
<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>
<PrintPage id={pagePDFContainerId(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>
</PrintPage>
);
}
@@ -132,11 +196,12 @@ async function PDFPageDocument(props: {
const document = page.documentId ? await getDocument(space.id, page.documentId) : null;
return (
<div
id={pagePDFContainerId(page)}
className={tcls('break-before-page', 'mt-10', 'print:mt-0')}
>
<h1 className={tcls('text-3xl', 'font-bold')}>{page.title}</h1>
<PrintPage id={pagePDFContainerId(page)}>
<h1 className={tcls('text-4xl', 'font-bold')}>{page.title}</h1>
{page.description ? (
<p className={tcls('decoration-primary/6', 'mt-2', 'mb-3')}>{page.description}</p>
) : null}
{document ? (
<DocumentView
document={document}
@@ -148,6 +213,42 @@ async function PDFPageDocument(props: {
}}
/>
) : null}
</PrintPage>
);
}
function PrintPage(
props: PolymorphicComponentProp<
'div',
{
isFirst?: boolean;
}
>,
) {
const { children, isFirst, className, ...rest } = props;
return (
<div
{...rest}
className={tcls(
className,
'my-11',
'print:my-0',
'mx-auto',
'max-w-4xl',
'w-full',
'p-12',
'print:p-0',
'shadow-xl',
'print:shadow-none',
'rounded-sm',
'bg-white',
'min-h-[29.7cm]',
'print:min-h-0',
isFirst ? null : 'break-before-page',
)}
>
{children}
</div>
);
}
@@ -157,7 +258,10 @@ type FlatPageEntry = { page: RevisionPageDocument | RevisionPageGroup; depth: nu
/**
* Compute the ordered flat set of pages to render.
*/
function selectPages(rootPages: Revision['pages'], params: PDFSearchParams): FlatPageEntry[] {
function selectPages(
rootPages: Revision['pages'],
params: PDFSearchParams,
): { pages: FlatPageEntry[]; total: number } {
const flattenPage = (
page: RevisionPageDocument | RevisionPageGroup,
depth: number,
@@ -170,6 +274,14 @@ function selectPages(rootPages: Revision['pages'], params: PDFSearchParams): Fla
];
};
const limitTo = (entries: FlatPageEntry[]) => {
return {
// Apply a soft-limit, the limit can be controlled by the URL to allow testing
pages: entries.slice(0, params.limit ?? 100),
total: entries.length,
};
};
if (params.page) {
const found = resolvePageId(rootPages, params.page);
if (!found) {
@@ -177,11 +289,14 @@ function selectPages(rootPages: Revision['pages'], params: PDFSearchParams): Fla
}
if (!params.only) {
return [{ page: found.page, depth: 0 }];
return limitTo([{ page: found.page, depth: 0 }]);
}
return flattenPage(found.page, 0);
return limitTo(flattenPage(found.page, 0));
}
return rootPages.flatMap((page) => (page.type === 'link' ? [] : flattenPage(page, 0)));
const allPages = rootPages.flatMap((page) =>
page.type === 'link' ? [] : flattenPage(page, 0),
);
return limitTo(allPages);
}
+31
View File
@@ -0,0 +1,31 @@
export interface PDFSearchParams {
/** Page to export. If none is passed, all pages are exported. */
page?: string;
/** If true, only the `page` is exported, and not its descendant */
only?: boolean;
/** Limit the number of pages */
limit?: number;
/** URL to redirect back to */
back?: string;
}
/**
* Generate a search params part of the URL for the PDF export.
*/
export function getPDFParams(params?: PDFSearchParams): string {
const searchParams = new URLSearchParams();
if (params?.page) {
searchParams.set('page', params.page);
}
if (params?.only) {
searchParams.set('only', 'yes');
}
if (params?.limit) {
searchParams.set('limit', String(params.limit));
}
if (params?.back) {
searchParams.set('back', String(params.back));
}
return searchParams.toString();
}
@@ -41,7 +41,7 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) {
};
return (
<button onClick={onClick} className={tcls(style)}>
<button onClick={onClick} className={tcls(style, 'print:hidden')}>
{t(language, copied ? 'code_copied' : 'code_copy')}
</button>
);
+1 -1
View File
@@ -49,7 +49,7 @@ export async function InlineImage(props: InlineProps<DocumentInlineImage>) {
style={[
inline.data.size === 'original'
? 'max-w-[300px]'
: ['max-h-[1.6em]', 'h-[1.6em]', 'w-auto'],
: ['max-h-[1lh]', 'h-[1lh]', 'w-auto'],
]}
inline
/>
+48 -25
View File
@@ -1,3 +1,4 @@
import DownloadCloud from '@geist-ui/icons/downloadCloud';
import Github from '@geist-ui/icons/github';
import Gitlab from '@geist-ui/icons/gitlab';
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
@@ -6,6 +7,7 @@ import urlJoin from 'url-join';
import { t, getSpaceLanguage } from '@/intl/server';
import { getDocumentSections } from '@/lib/document';
import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
import { ScrollSectionsList } from './ScrollSectionsList';
@@ -69,31 +71,52 @@ export function PageAside(props: {
<PageFeedbackForm spaceId={space.id} pageId={page.id} />
</React.Suspense>
) : null}
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'text-dark/6',
'hover:text-primary',
'py-2',
/* '[&>svg]:[stroke-opacity:0.64]', */
'dark:text-light/5',
)}
>
{getGitSyncName(space) === 'GitHub' ? (
<Github className={tcls('size-4', 'mr-1.5')} />
) : (
<Gitlab className={tcls('size-4', 'mr-1.5')} />
)}
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
<div>
{customization.git.showEditLink && space.gitSync?.url && page.git ? (
<div>
<a
href={urlJoin(space.gitSync.url, page.git.path)}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'text-dark/6',
'hover:text-primary',
'py-2',
'dark:text-light/5',
)}
>
{space.gitSync.installationProvider === 'gitlab' ? (
<Gitlab className={tcls('size-4', 'mr-1.5')} />
) : (
<Github className={tcls('size-4', 'mr-1.5')} />
)}
{t(language, 'edit_on_git', getGitSyncName(space))}
</a>
</div>
) : null}
{customization.pdf.enabled ? (
<div>
<a
href={absoluteHref('~gitbook/pdf')}
className={tcls(
'flex',
'flex-row',
'items-center',
'text-sm',
'text-dark/6',
'hover:text-primary',
'py-2',
'dark:text-light/5',
)}
>
<DownloadCloud className={tcls('size-4', 'mr-1.5')} />
{t(language, 'pdf_download')}
</a>
</div>
) : null}
</div>
</div>
</aside>
);
@@ -3,7 +3,7 @@
import Link from 'next/link';
import React from 'react';
import { IconChevronRight } from '@/components/icons/IconChevronRight';
import { useScrollActiveId } from '@/components/hooks';
import { DocumentSection } from '@/lib/document';
import { tcls } from '@/lib/tailwind';
@@ -16,64 +16,18 @@ const SECTION_INTERSECTING_THRESHOLD = 0.9;
export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
const { sections } = props;
const [activeId, setActiveId] = React.useState<string | null>(null);
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
React.useEffect(() => {
const onObserve: IntersectionObserverCallback = (entries) => {
/**
* We need to keep track of all the sections that are intersecting
* the viewport. This is because we want to find the first section
* that is visible on the viewport.
*/
entries.forEach((entry) => {
const sectionId = entry.target.id;
if (sectionId) {
sectionsIntersectingMap.current.set(
sectionId,
entry.isIntersecting &&
entry.intersectionRatio >= SECTION_INTERSECTING_THRESHOLD,
);
}
});
/**
* Find the first section that is intersecting the viewport (is visible)
*/
const firstActiveSection = Array.from(sectionsIntersectingMap.current.entries()).find(
([, isIntersecting]) => isIntersecting,
);
if (firstActiveSection) {
setActiveId(firstActiveSection[0]);
}
};
const observer = new IntersectionObserver(onObserve, {
rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
threshold: SECTION_INTERSECTING_THRESHOLD,
const ids = React.useMemo(() => {
return sections.map((section) => {
return section.id;
});
//sanitize sections from foreign characters
const sanitizedSections = sections.map((section) => {
return {
...section,
id: section.id.replace(/[^a-zA-Z0-9-_]/g, ''),
};
});
sanitizedSections.forEach((section) => {
const headingElement = document.querySelector(`#${section.id}`);
if (headingElement) {
observer.observe(headingElement);
}
});
return () => {
observer.disconnect();
};
}, [sections]);
const activeId = useScrollActiveId(ids, {
rootMargin: `-${HEADER_HEIGHT_DESKTOP}px 0px -40% 0px`,
threshold: SECTION_INTERSECTING_THRESHOLD,
});
return (
<ul className={tcls('border-l', 'border-dark/2', 'dark:border-light/1', 'space-y-1')}>
{sections.map((section) => (
@@ -104,11 +58,6 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) {
: '',
)}
>
{/* {section.depth > 1 ? (
<IconChevronRight
className={tcls('w-4', 'h-4', 'mr-1', 'mt-0.5', 'shrink-0')}
/>
) : null} */}
{section.title}
</Link>
</li>
+1
View File
@@ -0,0 +1 @@
export * from './useScrollActiveId';
+67
View File
@@ -0,0 +1,67 @@
import React from 'react';
/**
* Get the current ID being scrolled in the page.
*/
export function useScrollActiveId(
ids: string[],
options: {
rootMargin?: string;
threshold?: number;
} = {},
) {
const { rootMargin, threshold = 0.5 } = options;
const [activeId, setActiveId] = React.useState<string | null>(null);
const sectionsIntersectingMap = React.useRef<Map<string, boolean>>(new Map());
React.useEffect(() => {
setActiveId(null);
const onObserve: IntersectionObserverCallback = (entries) => {
/**
* We need to keep track of all the sections that are intersecting
* the viewport. This is because we want to find the first section
* that is visible on the viewport.
*/
entries.forEach((entry) => {
const sectionId = entry.target.id;
if (sectionId) {
sectionsIntersectingMap.current.set(
sectionId,
entry.isIntersecting && entry.intersectionRatio >= threshold,
);
}
});
/**
* Find the first section that is intersecting the viewport (is visible)
*/
const firstActiveSection = Array.from(sectionsIntersectingMap.current.entries()).find(
([, isIntersecting]) => isIntersecting,
);
if (firstActiveSection) {
setActiveId(firstActiveSection[0]);
}
};
const observer = new IntersectionObserver(onObserve, {
rootMargin,
threshold,
});
ids.forEach((id) => {
const element = document.querySelector(`#${id}`);
if (element) {
observer.observe(element);
}
});
return () => {
observer.disconnect();
};
}, [ids, threshold, rootMargin]);
return activeId;
}
+26 -15
View File
@@ -1,9 +1,12 @@
'use client';
import Link from 'next/link';
import { tcls, ClassValue } from '@/lib/tailwind';
type ButtonProps = {
onClick: () => void;
href?: string;
onClick?: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary';
size?: 'default' | 'small';
@@ -11,6 +14,7 @@ type ButtonProps = {
};
export function Button({
href,
onClick,
children,
variant = 'primary',
@@ -46,21 +50,28 @@ export function Button({
: // SMALL
['text-xs', 'px-3 py-2'];
const domClassName = tcls(
'rounded-md',
'place-self-start',
'ring-1',
'ring-inset',
'grow-0',
'shrink-0',
variantClasses,
sizeClasses,
className,
);
if (href) {
return (
<Link href={href} className={domClassName}>
{children}
</Link>
);
}
return (
<button
onClick={onClick}
className={tcls(
'rounded-md',
'place-self-start',
'ring-1',
'ring-inset',
'grow-0',
'shrink-0',
variantClasses,
sizeClasses,
className,
)}
>
<button onClick={onClick} className={domClassName}>
{children}
</button>
);
+8 -1
View File
@@ -34,5 +34,12 @@
"notfound_go_home": "Back to front page",
"unexpected_error_title": "An error occurred",
"unexpected_error": "Sorry, an unexpected error has occurred. Please try again later.",
"unexpected_error_retry": "Retry"
"unexpected_error_retry": "Retry",
"pdf_download": "Export as PDF",
"pdf_goback": "Go back to content",
"pdf_print": "Print or Save as PDF",
"pdf_page_of": "${1} of ${2}",
"pdf_mode_only_page": "Only this page",
"pdf_mode_all": "All pages",
"pdf_limit_reached": "Couldn't generate the PDF for ${1} pages, generation stopped at ${2}."
}