From 83f4cb5968031b83d4d95a65c822d8e4e9a61c2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Tue, 26 Dec 2023 23:14:46 +0100 Subject: [PATCH] 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 --- .../~gitbook/pdf/OpenPrintDialog.tsx | 13 - .../~gitbook/pdf/PageControlButtons.tsx | 128 ++++++++++ .../[spaceId]/~gitbook/pdf/PrintButton.tsx | 25 ++ src/app/[spaceId]/~gitbook/pdf/page.tsx | 229 +++++++++++++----- src/app/[spaceId]/~gitbook/pdf/params.ts | 31 +++ .../DocumentView/CodeBlock/CopyCodeButton.tsx | 2 +- src/components/DocumentView/InlineImage.tsx | 2 +- src/components/PageAside/PageAside.tsx | 73 ++++-- .../PageAside/ScrollSectionsList.tsx | 69 +----- src/components/hooks/index.ts | 1 + src/components/hooks/useScrollActiveId.ts | 67 +++++ src/components/primitives/Button.tsx | 41 ++-- src/intl/translations/en.json | 9 +- 13 files changed, 517 insertions(+), 173 deletions(-) delete mode 100644 src/app/[spaceId]/~gitbook/pdf/OpenPrintDialog.tsx create mode 100644 src/app/[spaceId]/~gitbook/pdf/PageControlButtons.tsx create mode 100644 src/app/[spaceId]/~gitbook/pdf/PrintButton.tsx create mode 100644 src/app/[spaceId]/~gitbook/pdf/params.ts create mode 100644 src/components/hooks/index.ts create mode 100644 src/components/hooks/useScrollActiveId.ts diff --git a/src/app/[spaceId]/~gitbook/pdf/OpenPrintDialog.tsx b/src/app/[spaceId]/~gitbook/pdf/OpenPrintDialog.tsx deleted file mode 100644 index 09b020da5..000000000 --- a/src/app/[spaceId]/~gitbook/pdf/OpenPrintDialog.tsx +++ /dev/null @@ -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; -} diff --git a/src/app/[spaceId]/~gitbook/pdf/PageControlButtons.tsx b/src/app/[spaceId]/~gitbook/pdf/PageControlButtons.tsx new file mode 100644 index 000000000..3f49e5f8d --- /dev/null +++ b/src/app/[spaceId]/~gitbook/pdf/PageControlButtons.tsx @@ -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 ( + <> +
+ + +
+ +
+ {total !== pageIds.length ? ( +
+ {' '} + {t(language, 'pdf_limit_reached', total, pageIds.length)} +
+ ) : null} + {/*
*/} +
+ {t(language, 'pdf_page_of', activeIndex, pageIds.length)} +
+ {/*
*/} +
+ + ); +} diff --git a/src/app/[spaceId]/~gitbook/pdf/PrintButton.tsx b/src/app/[spaceId]/~gitbook/pdf/PrintButton.tsx new file mode 100644 index 000000000..e98d159cc --- /dev/null +++ b/src/app/[spaceId]/~gitbook/pdf/PrintButton.tsx @@ -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 ( + + ); +} diff --git a/src/app/[spaceId]/~gitbook/pdf/page.tsx b/src/app/[spaceId]/~gitbook/pdf/page.tsx index 0340d7ab4..4b091c71f 100644 --- a/src/app/[spaceId]/~gitbook/pdf/page.tsx +++ b/src/app/[spaceId]/~gitbook/pdf/page.tsx @@ -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 { + 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 ( -
- + <> + {searchParams.back !== 'false' ? ( +
+ + + +
+ ) : null} + +
+ + + +
+ + + + {searchParams.only ? null : } {pages.map(({ page, depth }) => page.type === 'group' ? ( @@ -86,9 +148,7 @@ export default async function PDFHTMLOutput(props: { ), )} - - -
+ ); } @@ -96,9 +156,11 @@ async function SpaceIntro(props: { space: Space }) { const { space } = props; return ( -
-

{space.title}

-
+ +
+

{space.title}

+
+
); } @@ -106,19 +168,21 @@ async function PDFPageGroup(props: { space: Space; page: RevisionPageGroup }) { const { page } = props; return ( -
-

{page.title}

-
+ +
+

{page.title}

+
+
); } @@ -132,11 +196,12 @@ async function PDFPageDocument(props: { const document = page.documentId ? await getDocument(space.id, page.documentId) : null; return ( -
-

{page.title}

+ +

{page.title}

+ {page.description ? ( +

{page.description}

+ ) : null} + {document ? ( ) : null} +
+ ); +} + +function PrintPage( + props: PolymorphicComponentProp< + 'div', + { + isFirst?: boolean; + } + >, +) { + const { children, isFirst, className, ...rest } = props; + + return ( +
+ {children}
); } @@ -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); } diff --git a/src/app/[spaceId]/~gitbook/pdf/params.ts b/src/app/[spaceId]/~gitbook/pdf/params.ts new file mode 100644 index 000000000..15ebaca83 --- /dev/null +++ b/src/app/[spaceId]/~gitbook/pdf/params.ts @@ -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(); +} diff --git a/src/components/DocumentView/CodeBlock/CopyCodeButton.tsx b/src/components/DocumentView/CodeBlock/CopyCodeButton.tsx index 721318880..4643a76f7 100644 --- a/src/components/DocumentView/CodeBlock/CopyCodeButton.tsx +++ b/src/components/DocumentView/CodeBlock/CopyCodeButton.tsx @@ -41,7 +41,7 @@ export function CopyCodeButton(props: { codeId: string; style: ClassValue }) { }; return ( - ); diff --git a/src/components/DocumentView/InlineImage.tsx b/src/components/DocumentView/InlineImage.tsx index 66ab642cc..ea4e93c13 100644 --- a/src/components/DocumentView/InlineImage.tsx +++ b/src/components/DocumentView/InlineImage.tsx @@ -49,7 +49,7 @@ export async function InlineImage(props: InlineProps) { 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 /> diff --git a/src/components/PageAside/PageAside.tsx b/src/components/PageAside/PageAside.tsx index 6c87b71f7..f4bf19387 100644 --- a/src/components/PageAside/PageAside.tsx +++ b/src/components/PageAside/PageAside.tsx @@ -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: { ) : null} - {customization.git.showEditLink && space.gitSync?.url && page.git ? ( - - ) : null} +
+ {customization.git.showEditLink && space.gitSync?.url && page.git ? ( + + ) : null} + {customization.pdf.enabled ? ( + + ) : null} +
); diff --git a/src/components/PageAside/ScrollSectionsList.tsx b/src/components/PageAside/ScrollSectionsList.tsx index c5c84af89..f5fa578f4 100644 --- a/src/components/PageAside/ScrollSectionsList.tsx +++ b/src/components/PageAside/ScrollSectionsList.tsx @@ -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(null); - const sectionsIntersectingMap = React.useRef>(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 (
    {sections.map((section) => ( @@ -104,11 +58,6 @@ export function ScrollSectionsList(props: { sections: DocumentSection[] }) { : '', )} > - {/* {section.depth > 1 ? ( - - ) : null} */} {section.title} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts new file mode 100644 index 000000000..29fe0b0f9 --- /dev/null +++ b/src/components/hooks/index.ts @@ -0,0 +1 @@ +export * from './useScrollActiveId'; diff --git a/src/components/hooks/useScrollActiveId.ts b/src/components/hooks/useScrollActiveId.ts new file mode 100644 index 000000000..2aa0f2f48 --- /dev/null +++ b/src/components/hooks/useScrollActiveId.ts @@ -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(null); + const sectionsIntersectingMap = React.useRef>(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; +} diff --git a/src/components/primitives/Button.tsx b/src/components/primitives/Button.tsx index 87027bf6c..5a123b58d 100644 --- a/src/components/primitives/Button.tsx +++ b/src/components/primitives/Button.tsx @@ -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 ( + + {children} + + ); + } + return ( - ); diff --git a/src/intl/translations/en.json b/src/intl/translations/en.json index 3e3979cc5..d6a5db96a 100644 --- a/src/intl/translations/en.json +++ b/src/intl/translations/en.json @@ -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}." }