mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 11:26:31 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useScrollActiveId';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user