Progressive loading (#79)

* Suspense boundary and progressive loading on blocks

* Start refactoring to better leverage app router

* Use skeleton for page layout

* Improve dynamic toc

* Switch page full width to be client side only

* Format

* Remove old PageLoading

* Fix first page not marked as active

* Close search when clicking search link
This commit is contained in:
Samy Pessé
2023-12-28 14:38:15 +01:00
committed by GitHub
parent 1c2d68c518
commit 2fdf3e3838
41 changed files with 722 additions and 574 deletions
@@ -0,0 +1,25 @@
import { SkeletonHeading, SkeletonParagraph } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
/**
* Placeholder when loading a page.
*/
export default function PageSkeleton() {
return (
<div
className={tcls(
'relative',
'py-8',
'lg:px-12',
'flex-1',
'mr-56',
// withDesktopTableOfContents ? null : 'xl:ml-72',
)}
>
<div className={tcls('max-w-3xl', 'mx-auto')}>
<SkeletonHeading style={tcls('mb-8')} />
<SkeletonParagraph style={tcls('mb-4')} />
</div>
</div>
);
}
@@ -0,0 +1,108 @@
import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
import React from 'react';
import { PageAside } from '@/components/PageAside';
import { PageBody, PageCover } from '@/components/PageBody';
import { PageHrefContext, absoluteHref, pageHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { PagePathParams, fetchPageData, getPathnameParam } from '../../fetch';
export const runtime = 'edge';
/**
* Fetch and render a page.
*/
export default async function Page(props: { params: PagePathParams }) {
const { params } = props;
const { content, space, customization, pages, page, document } = await fetchPageData(params);
const linksContext: PageHrefContext = {};
if (!page) {
notFound();
} else if (getPagePath(pages, page) !== getPathnameParam(params)) {
redirect(pageHref(pages, page, linksContext));
}
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withFullPageCover = !!(
page.cover &&
page.layout.cover &&
page.layout.coverSize === 'full'
);
const withPageFeedback = customization.feedback.enabled;
const contentRefContext: ContentRefContext = {
space,
pages,
page,
content,
};
return (
<>
{withFullPageCover && page.cover ? (
<PageCover as="full" page={page} cover={page.cover} context={contentRefContext} />
) : null}
<div className={tcls('flex', 'flex-row')}>
<PageBody
space={space}
customization={customization}
context={contentRefContext}
page={page}
document={document}
withDesktopTableOfContents={!!page.layout.tableOfContents}
withAside={!!page.layout.outline}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/>
{page.layout.outline ? (
<PageAside
space={space}
customization={customization}
page={page}
document={document}
withHeaderOffset={withTopHeader}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
/>
) : null}
</div>
</>
);
}
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
? 'dark light'
: 'light dark'
: customization.themes.default,
};
}
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
const { space, page, customization } = await fetchPageData(params);
if (!page) {
notFound();
}
return {
title: `${page.title} | ${space.title}`,
description: page.description ?? '',
openGraph: {
images: [
customization.socialPreview.url ?? absoluteHref(`~gitbook/ogimage/${page.id}`),
],
},
};
}
@@ -1,25 +1,26 @@
import { CustomizationThemeMode } from '@gitbook/api';
import { Metadata, Viewport } from 'next';
import { notFound, redirect } from 'next/navigation';
import Script from 'next/script';
import React from 'react';
import { CookiesToast } from '@/components/Cookies';
import { SpaceContent } from '@/components/SpaceContent';
import { SpaceLayout } from '@/components/SpaceLayout';
import { getContentSecurityPolicyNonce } from '@/lib/csp';
import { PageHrefContext, absoluteHref, baseUrl, pageHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { absoluteHref, baseUrl } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
import { PagePathParams, fetchPageData, getPathnameParam } from '../fetch';
import { SpaceParams, fetchSpaceData } from '../fetch';
export const runtime = 'edge';
/**
* Fetch and render a page.
* Layout when rendering the content.
*/
export default async function Page(props: { params: PagePathParams }) {
const { params } = props;
export default async function ContentLayout(props: {
params: SpaceParams;
children: React.ReactNode;
}) {
const { params, children } = props;
const nonce = getContentSecurityPolicyNonce();
const {
@@ -27,34 +28,25 @@ export default async function Page(props: { params: PagePathParams }) {
space,
customization,
pages,
page,
collection,
collectionSpaces,
ancestors,
document,
scripts,
} = await fetchPageData(params);
const linksContext: PageHrefContext = {};
if (!page) {
notFound();
} else if (getPagePath(pages, page) !== getPathnameParam(params)) {
redirect(pageHref(pages, page, linksContext));
}
} = await fetchSpaceData(params);
return (
<>
<SpaceContent
content={content}
<SpaceLayout
space={space}
customization={customization}
pages={pages}
page={page}
ancestors={ancestors}
document={document}
collection={collection}
collectionSpaces={collectionSpaces}
/>
customization={customization}
pages={pages}
ancestors={ancestors}
content={content}
>
{children}
</SpaceLayout>
{scripts.map(({ script }) => (
<Script key={script} src={script} strategy="lazyOnload" nonce={nonce} />
@@ -69,8 +61,8 @@ export default async function Page(props: { params: PagePathParams }) {
);
}
export async function generateViewport({ params }: { params: PagePathParams }): Promise<Viewport> {
const { customization } = await fetchPageData(params);
export async function generateViewport({ params }: { params: SpaceParams }): Promise<Viewport> {
const { customization } = await fetchSpaceData(params);
return {
colorScheme: customization.themes.toggeable
? customization.themes.default === CustomizationThemeMode.Dark
@@ -80,17 +72,12 @@ export async function generateViewport({ params }: { params: PagePathParams }):
};
}
export async function generateMetadata({ params }: { params: PagePathParams }): Promise<Metadata> {
const { space, collection, page, customization } = await fetchPageData(params);
if (!page) {
notFound();
}
export async function generateMetadata({ params }: { params: SpaceParams }): Promise<Metadata> {
const { space, collection, customization } = await fetchSpaceData(params);
const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null;
return {
title: `${page.title} | ${space.title}`,
description: page.description ?? '',
title: `${space.title}`,
generator: 'GitBook',
// We pass `metadataBase` to avoid warnings from Next, but we still use absolute URLs
// as metadataBase doesn't seem to work well on next-on-cloudflare.
@@ -113,11 +100,6 @@ export async function generateMetadata({ params }: { params: PagePathParams }):
},
],
},
openGraph: {
images: [
customization.socialPreview.url ?? absoluteHref(`~gitbook/ogimage/${page.id}`),
],
},
robots: shouldIndexSpace({ space, collection }) ? 'index, follow' : 'noindex, nofollow',
};
}
@@ -5,7 +5,7 @@ import { getCollection, getSpace } from '@/lib/api';
import { absoluteHref } from '@/lib/links';
import { shouldIndexSpace } from '@/lib/seo';
import { SpaceParams } from '../fetch';
import { SpaceParams } from '../../fetch';
export const runtime = 'edge';
@@ -5,7 +5,7 @@ import { NextRequest } from 'next/server';
import { getRevisionPages } from '@/lib/api';
import { pageHref } from '@/lib/links';
import { SpaceParams } from '../fetch';
import { SpaceParams } from '../../fetch';
export const runtime = 'edge';
@@ -8,7 +8,7 @@ import { getCollection, getSpace, getSpaceCustomization } from '@/lib/api';
import { getEmojiForCode } from '@/lib/emojis';
import { tcls } from '@/lib/tailwind';
import { SpaceParams } from '../../fetch';
import { SpaceParams } from '../../../fetch';
export const runtime = 'edge';
@@ -3,7 +3,7 @@ import { ImageResponse } from 'next/og';
import { NextRequest } from 'next/server';
import React from 'react';
import { PageIdParams, fetchPageData } from '../../../fetch';
import { PageIdParams, fetchPageData } from '../../../../fetch';
export const runtime = 'edge';
@@ -25,7 +25,7 @@ import './pdf.css';
import { PageControlButtons } from './PageControlButtons';
import { PDFSearchParams, getPDFSearchParams } from './params';
import { PrintButton } from './PrintButton';
import { SpaceParams } from '../../fetch';
import { SpaceParams } from '../../../fetch';
export const runtime = 'edge';
+26 -2
View File
@@ -5,8 +5,8 @@ import {
getCollection,
ContentPointer,
getSpaceContent,
getDocument,
getRevisionPageByPath,
getDocument,
} from '@/lib/api';
import { resolvePagePath, resolvePageId } from '@/lib/pages';
@@ -20,6 +20,30 @@ export interface PageIdParams extends SpaceParams {
pageId?: string;
}
/**
* Fetch all the data needed to render the space layout.
*/
export async function fetchSpaceData(params: PagePathParams | PageIdParams) {
const content: ContentPointer = {
spaceId: params.spaceId,
changeRequestId: params.changeRequestId,
revisionId: params.revisionId,
};
const { space, pages, customization, scripts } = await getSpaceContent(content);
const collection = await fetchParentCollection(space);
return {
content,
space,
pages,
customization,
scripts,
ancestors: [],
...collection,
};
}
/**
* Fetch all the data needed to render the content.
* Optimized to fetch in parallel as much as possible.
@@ -36,7 +60,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) {
const page = await resolvePage(pages, content, params);
const [collection, document] = await Promise.all([
fetchParentCollection(space),
page && page.page.documentId ? await getDocument(space.id, page.page.documentId) : null,
page?.page.documentId ? getDocument(space.id, page.page.documentId) : null,
]);
return {
+4
View File
@@ -12,6 +12,10 @@ import { ClientContexts } from './ClientContexts';
import { SpaceParams } from './fetch';
import './globals.css';
/**
* Layout shared between the content and the PDF renderer.
* It takes care of setting the theme and the language.
*/
export default async function SpaceRootLayout(props: {
children: React.ReactNode;
params: SpaceParams;
+86 -31
View File
@@ -1,6 +1,13 @@
import { DocumentBlock, JSONDocument } from '@gitbook/api';
import assertNever from 'assert-never';
import React from 'react';
import {
SkeletonParagraph,
SkeletonHeading,
SkeletonCard,
SkeletonImage,
} from '@/components/primitives';
import { ClassValue } from '@/lib/tailwind';
import { BlockContentRef } from './BlockContentRef';
@@ -35,55 +42,103 @@ export interface BlockProps<Block extends DocumentBlock> extends DocumentContext
export function Block<T extends DocumentBlock>(props: BlockProps<T>) {
const { block, style, ...contextProps } = props;
const content = (() => {
switch (block.type) {
case 'paragraph':
return <Paragraph {...props} {...contextProps} block={block} />;
case 'heading-1':
case 'heading-2':
case 'heading-3':
return <Heading {...props} {...contextProps} block={block} />;
case 'list-ordered':
return <ListOrdered {...props} {...contextProps} block={block} />;
case 'list-unordered':
return <ListUnordered {...props} {...contextProps} block={block} />;
case 'list-tasks':
return <ListTasks {...props} {...contextProps} block={block} />;
case 'list-item':
return <ListItem {...props} {...contextProps} block={block} />;
case 'code':
return <CodeBlock {...props} {...contextProps} block={block} />;
case 'hint':
return <Hint {...props} {...contextProps} block={block} />;
case 'images':
return <Images {...props} {...contextProps} block={block} />;
case 'tabs':
return <Tabs {...props} {...contextProps} block={block} />;
case 'expandable':
return <Expandable {...props} {...contextProps} block={block} />;
case 'table':
return <Table {...props} {...contextProps} block={block} />;
case 'swagger':
return <Swagger {...props} {...contextProps} block={block} />;
case 'embed':
return <Embed {...props} {...contextProps} block={block} />;
case 'blockquote':
return <Quote {...props} {...contextProps} block={block} />;
case 'math':
return <BlockMath {...props} {...contextProps} block={block} />;
case 'file':
return <File {...props} {...contextProps} block={block} />;
case 'divider':
return <Divider {...props} {...contextProps} block={block} />;
case 'drawing':
return <Drawing {...props} {...contextProps} block={block} />;
case 'content-ref':
return <BlockContentRef {...props} {...contextProps} block={block} />;
case 'image':
case 'code-line':
case 'tabs-item':
throw new Error('Blocks should be directly rendered by parent');
case 'integration':
return <div>TODO Not supported yet</div>;
default:
assertNever(block);
}
})();
return (
<React.Suspense fallback={<BlockPlaceholder block={block} style={style} />}>
{content}
</React.Suspense>
);
}
function BlockPlaceholder(props: { block: DocumentBlock; style: ClassValue }) {
const { block, style } = props;
switch (block.type) {
case 'paragraph':
return <Paragraph {...props} {...contextProps} block={block} />;
case 'heading-1':
case 'heading-2':
case 'heading-3':
return <Heading {...props} {...contextProps} block={block} />;
return <SkeletonHeading style={style} />;
case 'paragraph':
case 'list-ordered':
return <ListOrdered {...props} {...contextProps} block={block} />;
case 'list-unordered':
return <ListUnordered {...props} {...contextProps} block={block} />;
case 'list-tasks':
return <ListTasks {...props} {...contextProps} block={block} />;
case 'list-item':
return <ListItem {...props} {...contextProps} block={block} />;
case 'code':
return <CodeBlock {...props} {...contextProps} block={block} />;
case 'hint':
return <Hint {...props} {...contextProps} block={block} />;
case 'images':
return <Images {...props} {...contextProps} block={block} />;
case 'tabs':
return <Tabs {...props} {...contextProps} block={block} />;
case 'expandable':
return <Expandable {...props} {...contextProps} block={block} />;
case 'table':
return <Table {...props} {...contextProps} block={block} />;
case 'swagger':
return <Swagger {...props} {...contextProps} block={block} />;
case 'embed':
return <Embed {...props} {...contextProps} block={block} />;
case 'blockquote':
return <Quote {...props} {...contextProps} block={block} />;
case 'code':
case 'hint':
return <SkeletonParagraph style={style} />;
case 'tabs':
case 'expandable':
case 'table':
case 'swagger':
case 'math':
return <BlockMath {...props} {...contextProps} block={block} />;
case 'file':
return <File {...props} {...contextProps} block={block} />;
case 'divider':
return <Divider {...props} {...contextProps} block={block} />;
case 'drawing':
return <Drawing {...props} {...contextProps} block={block} />;
case 'content-ref':
return <BlockContentRef {...props} {...contextProps} block={block} />;
case 'integration':
return <SkeletonCard style={style} />;
case 'embed':
case 'images':
return <SkeletonImage style={style} />;
case 'image':
case 'code-line':
case 'tabs-item':
throw new Error('Blocks should be directly rendered by parent');
case 'integration':
return <div>TODO Not supported yet</div>;
default:
assertNever(block);
}
+3 -13
View File
@@ -6,16 +6,15 @@ import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { FooterLinksGroup } from './FooterLinksGroup';
import { CONTAINER_MAX_WIDTH_NORMAL, CONTAINER_PADDING } from '../layout';
import { CONTAINER_STYLE } from '../layout';
import { ThemeToggler } from '../ThemeToggler';
export function Footer(props: {
space: Space;
context: ContentRefContext;
customization: CustomizationSettings;
asFullWidth: boolean;
}) {
const { context, customization, asFullWidth } = props;
const { context, customization } = props;
return (
<div
@@ -27,16 +26,7 @@ export function Footer(props: {
'dark:bg-dark-2',
)}
>
<div
className={tcls(
'flex',
'flex-row',
CONTAINER_PADDING,
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
'py-6',
)}
>
<div className={tcls('flex', 'flex-row', CONTAINER_STYLE, 'py-6')}>
<div className={tcls('flex-1', 'flex', 'flex-col', 'gap-6')}>
{customization.footer.logo || customization.footer.groups?.length > 0 ? (
<div className={tcls('flex', 'flex-row', 'gap-20')}>
+3 -21
View File
@@ -2,11 +2,7 @@ import { Collection, CustomizationSettings, Space } from '@gitbook/api';
import { CustomizationHeaderPreset } from '@gitbook/api';
import { Suspense } from 'react';
import {
CONTAINER_MAX_WIDTH_NORMAL,
CONTAINER_PADDING,
HEADER_HEIGHT_DESKTOP,
} from '@/components/layout';
import { CONTAINER_STYLE, HEADER_HEIGHT_DESKTOP } from '@/components/layout';
import { t, getSpaceLanguage } from '@/intl/server';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -24,27 +20,14 @@ export function Header(props: {
collection: Collection | null;
collectionSpaces: Space[];
context: ContentRefContext;
asFullWidth: boolean;
customization: CustomizationSettings;
withTopHeader?: boolean;
}) {
const {
context,
space,
collection,
collectionSpaces,
asFullWidth,
customization,
withTopHeader,
} = props;
const { context, space, collection, collectionSpaces, customization, withTopHeader } = props;
const isCustomizationDefault =
customization.header.preset === CustomizationHeaderPreset.Default;
const isCustomizationCustom = customization.header.preset === CustomizationHeaderPreset.Custom;
console.log('Header.tsx: isCustomizationDefault: ', isCustomizationDefault);
return (
<header
className={tcls(
@@ -79,8 +62,7 @@ export function Header(props: {
'align-center',
'justify-between',
'w-full',
CONTAINER_PADDING,
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
CONTAINER_STYLE,
)}
>
<HeaderLogo collection={collection} space={space} customization={customization} />
+4 -2
View File
@@ -6,6 +6,7 @@ import React from 'react';
import urlJoin from 'url-join';
import { t, getSpaceLanguage } from '@/intl/server';
import { getDocument } from '@/lib/api';
import { getDocumentSections } from '@/lib/document';
import { absoluteHref } from '@/lib/links';
import { tcls } from '@/lib/tailwind';
@@ -16,7 +17,7 @@ import { PageFeedbackForm } from '../PageFeedback';
/**
* Aside listing the headings in the document.
*/
export function PageAside(props: {
export async function PageAside(props: {
space: Space;
customization: CustomizationSettings;
page: RevisionPageDocument;
@@ -25,7 +26,8 @@ export function PageAside(props: {
withFullPageCover: boolean;
withPageFeedback: boolean;
}) {
const { space, page, customization, document, withHeaderOffset, withPageFeedback } = props;
const { space, page, document, customization, withHeaderOffset, withPageFeedback } = props;
const sections = document ? getDocumentSections(document) : [];
const language = getSpaceLanguage(customization);
+55 -53
View File
@@ -1,13 +1,14 @@
import { CustomizationSettings, JSONDocument, RevisionPageDocument, Space } from '@gitbook/api';
import React from 'react';
import { hasFullWidthBlock } from '@/lib/document';
import { ContentRefContext, resolveContentRef } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { PageCover } from './PageCover';
import { PageFooterNavigation } from './PageFooterNavigation';
import { PageHeader } from './PageHeader';
import { PageLoading } from './PageLoading';
import { TogglePageFullWidth } from './TogglePageFullWidth';
import { DocumentView } from '../DocumentView';
import { PageFeedbackForm } from '../PageFeedback';
@@ -15,8 +16,8 @@ export function PageBody(props: {
space: Space;
customization: CustomizationSettings;
page: RevisionPageDocument;
context: ContentRefContext;
document: JSONDocument | null;
context: ContentRefContext;
withDesktopTableOfContents: boolean;
withAside: boolean;
withPageFeedback: boolean;
@@ -32,60 +33,61 @@ export function PageBody(props: {
withPageFeedback,
} = props;
const asFullWidth = document ? hasFullWidthBlock(document) : false;
return (
<main
className={tcls(
'relative',
'py-8',
'lg:px-12',
'flex-1',
withAside ? null : 'mr-56',
withDesktopTableOfContents ? null : 'xl:ml-72',
)}
>
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
<>
{asFullWidth ? <TogglePageFullWidth /> : null}
<main
className={tcls(
'relative',
'py-8',
'lg:px-12',
'flex-1',
withAside ? null : 'mr-56',
withDesktopTableOfContents ? null : 'xl:ml-72',
)}
>
{page.cover && page.layout.cover && page.layout.coverSize === 'hero' ? (
<PageCover as="hero" page={page} cover={page.cover} context={context} />
) : null}
<PageHeader page={page} />
{document ? (
<DocumentView
document={document}
style={['space-y-5', 'grid']}
context={{
content: context.content,
resolveContentRef: (ref) => resolveContentRef(ref, context),
}}
/>
) : null}
<PageHeader page={page} />
{document ? (
<DocumentView
document={document}
style={['space-y-5', 'grid']}
context={{
content: context.content,
resolveContentRef: (ref) => resolveContentRef(ref, context),
}}
/>
) : null}
{page.layout.pagination ? (
<PageFooterNavigation
space={space}
customization={customization}
pages={context.pages}
page={page}
/>
) : null}
{page.layout.pagination ? (
<PageFooterNavigation
space={space}
customization={customization}
pages={context.pages}
page={page}
/>
) : null}
{withPageFeedback ? (
<div
className={tcls(
'flex',
'flex-row',
'justify-end',
'mt-6',
'max-w-3xl',
'mx-auto',
)}
>
<PageFeedbackForm spaceId={space.id} pageId={page.id} />
</div>
) : null}
<React.Suspense fallback={null}>
<PageLoading />
</React.Suspense>
</main>
{withPageFeedback ? (
<div
className={tcls(
'flex',
'flex-row',
'justify-end',
'mt-6',
'max-w-3xl',
'mx-auto',
)}
>
<PageFeedbackForm spaceId={space.id} pageId={page.id} />
</div>
) : null}
</main>
</>
);
}
-43
View File
@@ -1,43 +0,0 @@
'use client';
import { Loading } from '@/components/primitives';
import { tcls } from '@/lib/tailwind';
import { useIsLoadingPage } from '../state';
/**
* When navigating between pages, display an overlay on top of the page.
*/
export function PageLoading(props: {}) {
const loading = useIsLoadingPage();
return (
<div className={tcls('absolute', 'grid', 'inset-0', 'pointer-events-none')}>
<div
className={tcls(
'grid-area-1-1',
'bg-light',
'grid',
loading ? 'opacity-8' : ['opacity-0'],
'transition-opacity',
'dark:bg-dark',
)}
></div>
<div
className={tcls(
'grid-area-1-1',
'flex',
'sticky',
'top-0',
'h-[100vh]',
'items-center',
'justify-center',
'transition-opacity',
loading ? 'opacity-[1]' : ['opacity-0'],
)}
>
<Loading className={tcls('w-6', 'text-primary')} />
</div>
</div>
);
}
@@ -0,0 +1,19 @@
'use client';
import React from 'react';
/**
* Client component to toggle the global class `page-full-width` on the body element.
* It indicates that the page is rendered full width.
*/
export function TogglePageFullWidth() {
React.useLayoutEffect(() => {
document.body.classList.add('page-full-width');
return () => {
document.body.classList.remove('page-full-width');
};
});
return null;
}
+1
View File
@@ -195,6 +195,7 @@ function SearchModalBody(
query: state.query,
});
}}
onClose={onClose}
/>
) : null}
{state.query && state.ask && withAsk ? (
@@ -11,15 +11,17 @@ export const SearchPageResultItem = React.forwardRef(function SearchPageResultIt
query: string;
item: ComputedPageResult;
active: boolean;
onClick: () => void;
},
ref: React.Ref<HTMLAnchorElement>,
) {
const { query, item, active } = props;
const { query, item, active, onClick } = props;
return (
<Link
ref={ref}
href={item.href}
onClick={onClick}
className={tcls(
'flex',
'flex-row',
+4 -1
View File
@@ -33,10 +33,11 @@ export const SearchResults = React.forwardRef(function SearchResults(
spaceId: string;
withAsk: boolean;
onSwitchToAsk: () => void;
onClose: () => void;
},
ref: React.Ref<SearchResultsRef>,
) {
const { query, spaceId, withAsk, onSwitchToAsk } = props;
const { query, spaceId, withAsk, onSwitchToAsk, onClose } = props;
const language = useLanguage();
const debounceTimeout = React.useRef<NodeJS.Timeout | null>(null);
@@ -188,6 +189,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query}
item={item}
active={index === cursor}
onClick={onClose}
/>
);
}
@@ -228,6 +230,7 @@ export const SearchResults = React.forwardRef(function SearchResults(
query={query}
item={item}
active={index === cursor}
onClick={onClose}
/>
);
}
@@ -7,15 +7,16 @@ import { HighlightQuery } from './HighlightQuery';
import type { ComputedSectionResult } from './server-actions';
export const SearchSectionResultItem = React.forwardRef(function SearchSectionResultItem(
props: { query: string; item: ComputedSectionResult; active: boolean },
props: { query: string; item: ComputedSectionResult; active: boolean; onClick: () => void },
ref: React.Ref<HTMLAnchorElement>,
) {
const { query, item, active } = props;
const { query, item, active, onClick } = props;
return (
<Link
ref={ref}
href={item.href}
onClick={onClick}
className={tcls(
'search-section-result-item',
'flex',
@@ -1,165 +0,0 @@
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
JSONDocument,
Revision,
RevisionPageDocument,
RevisionPageGroup,
Space,
} from '@gitbook/api';
import React from 'react';
import { Footer } from '@/components/Footer';
import { CompactHeader, Header } from '@/components/Header';
import { CONTAINER_MAX_WIDTH_NORMAL, CONTAINER_PADDING } from '@/components/layout';
import { PageBody, PageCover } from '@/components/PageBody';
import { SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { ContentPointer } from '@/lib/api';
import { hasFullWidthBlock } from '@/lib/document';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
import { PageAside } from '../PageAside';
/**
* Render the entire content of the space (header, table of contents, footer, and page content).
*/
export function SpaceContent(props: {
content: ContentPointer;
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
pages: Revision['pages'];
page: RevisionPageDocument;
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
document: JSONDocument | null;
}) {
const {
space,
collection,
collectionSpaces,
content,
pages,
customization,
page,
ancestors,
document,
} = props;
const asFullWidth = document ? hasFullWidthBlock(document) : false;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const withFullPageCover = !!(
page.cover &&
page.layout.cover &&
page.layout.coverSize === 'full'
);
const withPageFeedback = customization.feedback.enabled;
const contentRefContext: ContentRefContext = {
space,
pages,
page,
content,
};
return (
<div>
<Header
withTopHeader={withTopHeader}
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
context={contentRefContext}
customization={customization}
asFullWidth={asFullWidth}
/>
<div
className={tcls(
'flex',
'flex-col',
'lg:flex-row',
CONTAINER_PADDING,
asFullWidth ? null : [CONTAINER_MAX_WIDTH_NORMAL, 'mx-auto'],
)}
>
<TableOfContents
space={space}
customization={customization}
content={content}
pages={pages}
activePage={page}
ancestors={ancestors}
context={contentRefContext}
header={
withTopHeader ? null : (
<CompactHeader
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
customization={customization}
/>
)
}
withHeaderOffset={withTopHeader}
visibleOnDesktop={!!page.layout.tableOfContents}
/>
<div className={tcls('flex-1', 'flex', 'flex-col')}>
{withFullPageCover && page.cover ? (
<PageCover
as="full"
page={page}
cover={page.cover}
context={contentRefContext}
/>
) : null}
<div className={tcls('flex', 'flex-row')}>
<PageBody
space={space}
customization={customization}
context={contentRefContext}
page={page}
document={document}
withDesktopTableOfContents={!!page.layout.tableOfContents}
withAside={!!page.layout.outline}
withPageFeedback={
// Display the page feedback in the page footer if the aside is not visible
withPageFeedback && !page.layout.outline
}
/>
{page.layout.outline ? (
<PageAside
space={space}
customization={customization}
page={page}
document={document}
withHeaderOffset={withTopHeader}
withFullPageCover={withFullPageCover}
withPageFeedback={withPageFeedback}
/>
) : null}
</div>
</div>
</div>
{customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
customization.footer.groups?.length ? (
<Footer
space={space}
context={contentRefContext}
customization={customization}
asFullWidth={asFullWidth}
/>
) : null}
<React.Suspense fallback={null}>
<SearchModal spaceId={space.id} withAsk={customization.aiSearch.enabled} />
</React.Suspense>
</div>
);
}
-1
View File
@@ -1 +0,0 @@
export * from './SpaceContent';
+102
View File
@@ -0,0 +1,102 @@
import {
Collection,
CustomizationHeaderPreset,
CustomizationSettings,
Revision,
RevisionPageDocument,
RevisionPageGroup,
Space,
} from '@gitbook/api';
import React from 'react';
import { Footer } from '@/components/Footer';
import { CompactHeader, Header } from '@/components/Header';
import { CONTAINER_STYLE } from '@/components/layout';
import { SearchModal } from '@/components/Search';
import { TableOfContents } from '@/components/TableOfContents';
import { ContentPointer } from '@/lib/api';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
/**
* Render the entire content of the space (header, table of contents, footer, and page content).
*/
export function SpaceLayout(props: {
content: ContentPointer;
space: Space;
collection: Collection | null;
collectionSpaces: Space[];
customization: CustomizationSettings;
pages: Revision['pages'];
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
children: React.ReactNode;
}) {
const {
space,
collection,
collectionSpaces,
content,
pages,
customization,
ancestors,
children,
// document,
} = props;
// const asFullWidth = document ? hasFullWidthBlock(document) : false;
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
const contentRefContext: ContentRefContext = {
space,
pages,
content,
};
return (
<div>
<Header
withTopHeader={withTopHeader}
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
context={contentRefContext}
customization={customization}
/>
<div className={tcls('flex', 'flex-col', 'lg:flex-row', CONTAINER_STYLE)}>
<TableOfContents
space={space}
customization={customization}
content={content}
pages={pages}
ancestors={ancestors}
context={contentRefContext}
header={
withTopHeader ? null : (
<CompactHeader
space={space}
collection={collection}
collectionSpaces={collectionSpaces}
customization={customization}
/>
)
}
withHeaderOffset={withTopHeader}
visibleOnDesktop={true /*!!page.layout.tableOfContents */}
/>
<div className={tcls('flex-1', 'flex', 'flex-col')}>{children}</div>
</div>
{customization.themes.toggeable ||
customization.footer.copyright ||
customization.footer.logo ||
customization.footer.groups?.length ? (
<Footer space={space} context={contentRefContext} customization={customization} />
) : null}
<React.Suspense fallback={null}>
<SearchModal spaceId={space.id} withAsk={customization.aiSearch.enabled} />
</React.Suspense>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export * from './SpaceLayout';
@@ -1,7 +1,7 @@
import { RevisionPage, RevisionPageDocument, RevisionPageGroup } from '@gitbook/api';
import Link from 'next/link';
import { pageHref } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { ContentRefContext } from '@/lib/references';
import { tcls } from '@/lib/tailwind';
@@ -11,64 +11,18 @@ import { ToggleableLinkItem } from './ToggleableLinkItem';
export function PageDocumentItem(props: {
rootPages: RevisionPage[];
page: RevisionPageDocument;
activePage: RevisionPageDocument;
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
context: ContentRefContext;
}) {
const { rootPages, page, activePage, ancestors, context } = props;
const hasActiveDescendant = ancestors.some((ancestor) => ancestor.id === page.id);
const linkProps = {
href: pageHref(rootPages, page),
className: tcls(
'flex',
'flex-row',
'justify-between',
'pl-5',
'pr-1.5',
'py-1.5',
'text-sm',
'transition-colors',
'relative',
'textwrap-balance',
'before:border-l',
'before:absolute',
'before:left-[-1px]',
'before:top-0',
'before:h-full',
'rounded-md',
'[&+div_a]:rounded-l-none',
activePage.id === page.id
? [
'before:border-primary/6',
'font-semibold',
'text-primary',
'hover:bg-primary/3',
'dark:text-primary-400',
'hover:before:border-primary',
'dark:hover:bg-primary-500/3',
'dark:hover:before:border-primary',
]
: [
'before:border-transparent',
'font-normal',
'text-dark/8',
'hover:bg-dark/1',
'hover:before:border-dark/3',
'dark:text-light/7',
'dark:hover:bg-light/2',
'dark:hover:before:border-light/3',
],
),
};
const { rootPages, page, ancestors, context } = props;
return (
<li className={tcls('flex', 'flex-col')}>
{page.pages && page.pages.length ? (
<ToggleableLinkItem
{...linkProps}
descendants={
<ToggleableLinkItem
href={pageHref(rootPages, page)}
pathname={getPagePath(rootPages, page)}
descendants={
page.pages && page.pages.length ? (
<PagesList
rootPages={rootPages}
pages={page.pages}
@@ -79,19 +33,14 @@ export function PageDocumentItem(props: {
'border-dark/3',
'dark:border-light/2',
)}
activePage={activePage}
ancestors={ancestors}
context={context}
/>
}
defaultOpen={hasActiveDescendant || activePage.id === page.id}
isActive={activePage.id === page.id}
>
{page.title}
</ToggleableLinkItem>
) : (
<Link {...linkProps}>{page.title}</Link>
)}
) : null
}
>
{page.title}
</ToggleableLinkItem>
</li>
);
}
@@ -8,11 +8,10 @@ import { PagesList } from './PagesList';
export function PageGroupItem(props: {
rootPages: RevisionPage[];
page: RevisionPageGroup;
activePage: RevisionPageDocument;
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
context: ContentRefContext;
}) {
const { rootPages, page, activePage, ancestors, context } = props;
const { rootPages, page, ancestors, context } = props;
return (
<li className={tcls('flex', 'flex-col')}>
@@ -43,7 +42,6 @@ export function PageGroupItem(props: {
<PagesList
rootPages={rootPages}
pages={page.pages}
activePage={activePage}
ancestors={ancestors}
context={context}
/>
+1 -4
View File
@@ -10,12 +10,11 @@ import { PageLinkItem } from './PageLinkItem';
export function PagesList(props: {
rootPages: RevisionPage[];
pages: RevisionPage[];
activePage: RevisionPageDocument;
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
context: ContentRefContext;
style?: ClassValue;
}) {
const { rootPages, pages, activePage, ancestors, context, style } = props;
const { rootPages, pages, ancestors, context, style } = props;
return (
<ul className={tcls('flex', 'flex-1', 'flex-col', 'gap-y-0.5', style)}>
@@ -26,7 +25,6 @@ export function PagesList(props: {
key={page.id}
rootPages={rootPages}
page={page}
activePage={activePage}
ancestors={ancestors}
context={context}
/>
@@ -40,7 +38,6 @@ export function PagesList(props: {
key={page.id}
rootPages={rootPages}
page={page}
activePage={activePage}
ancestors={ancestors}
context={context}
/>
@@ -20,7 +20,6 @@ export function TableOfContents(props: {
content: ContentPointer;
context: ContentRefContext;
pages: Revision['pages'];
activePage: RevisionPageDocument;
ancestors: Array<RevisionPageDocument | RevisionPageGroup>;
header?: React.ReactNode;
withHeaderOffset: boolean;
@@ -30,7 +29,6 @@ export function TableOfContents(props: {
space,
customization,
pages,
activePage,
ancestors,
header,
context,
@@ -95,7 +93,6 @@ export function TableOfContents(props: {
<PagesList
rootPages={pages}
pages={pages}
activePage={activePage}
ancestors={ancestors}
context={context}
/>
@@ -2,15 +2,12 @@
import IconChevronRight from '@geist-ui/icons/chevronRight';
import { motion, stagger, useAnimate } from 'framer-motion';
import Link, { LinkProps } from 'next/link';
import React, { useState } from 'react';
import Link from 'next/link';
import { useSelectedLayoutSegment } from 'next/navigation';
import React from 'react';
import { tcls } from '@/lib/tailwind';
/**
* Client component to allow toggling of a page's children.
*/
const show = {
opacity: 1,
height: 'auto',
@@ -25,30 +22,53 @@ const hide = {
},
};
export function ToggleableLinkItem(
props: LinkProps & {
className?: string;
children: React.ReactNode;
descendants?: React.ReactNode;
defaultOpen?: boolean;
isActive?: boolean;
},
) {
const { children, descendants, defaultOpen = false, isActive = false, ...linkProps } = props;
const staggerMenuItems = stagger(0.02, { ease: (p) => Math.pow(p, 2) });
const staggerMenuItems = stagger(0.02, { ease: (p) => Math.pow(p, 2) });
/**
* Client component for a page document to toggle its children and be marked as active.
*/
export function ToggleableLinkItem(props: {
href: string;
pathname: string;
children: React.ReactNode;
descendants: React.ReactNode;
}) {
const { href, children, descendants, pathname } = props;
const activeSegment = useSelectedLayoutSegment() ?? '';
const isActive = activeSegment === pathname;
const hasDescendants = !!descendants;
const hasActiveDescendant =
hasDescendants && (isActive || activeSegment.startsWith(pathname + '/'));
const [scope, animate] = useAnimate();
const [isVisible, setIsVisible] = useState(defaultOpen);
const [isVisible, setIsVisible] = React.useState(hasActiveDescendant);
const toggleVisibility = () => {
const willBecomeVisible = !isVisible;
setIsVisible(willBecomeVisible);
// Update the visibility of the children, if we are navigating to a descendant.
React.useEffect(() => {
if (!hasDescendants) {
return;
}
setIsVisible((prev) => prev || hasActiveDescendant);
animate(scope.current, willBecomeVisible ? show : hide, {
if (hasActiveDescendant) {
console.log('activeSegment', { isActive, activeSegment, pathname });
}
}, [hasActiveDescendant, hasDescendants]);
// Animate the visibility of the children
// only after the initial state.
React.useEffect(() => {
if (!mountedRef.current || !hasDescendants) {
return;
}
animate(scope.current, isVisible ? show : hide, {
duration: 0.1,
});
if (willBecomeVisible)
if (isVisible)
animate(
'& > ul > li',
{ opacity: 1 },
@@ -56,65 +76,119 @@ export function ToggleableLinkItem(
delay: staggerMenuItems,
},
);
else animate('& > ul > li', { opacity: 0 });
};
else {
animate('& > ul > li', { opacity: 0 });
}
}, [isVisible, hasDescendants]);
// Track if the component is mounted.
const mountedRef = React.useRef(false);
React.useEffect(() => {
mountedRef.current = true;
}, []);
return (
<div>
<Link {...linkProps}>
<Link
href={href}
className={tcls(
'flex',
'flex-row',
'justify-between',
'pl-5',
'pr-1.5',
'py-1.5',
'text-sm',
'transition-colors',
'relative',
'textwrap-balance',
'before:border-l',
'before:absolute',
'before:left-[-1px]',
'before:top-0',
'before:h-full',
'rounded-md',
'[&+div_a]:rounded-l-none',
isActive
? [
'before:border-primary/6',
'font-semibold',
'text-primary',
'hover:bg-primary/3',
'dark:text-primary-400',
'hover:before:border-primary',
'dark:hover:bg-primary-500/3',
'dark:hover:before:border-primary',
]
: [
'before:border-transparent',
'font-normal',
'text-dark/8',
'hover:bg-dark/1',
'hover:before:border-dark/3',
'dark:text-light/7',
'dark:hover:bg-light/2',
'dark:hover:before:border-light/3',
],
)}
>
{children}
<span
className={tcls(
'group',
'relative',
'rounded-full',
'w-5',
'h-5',
'after:grid-area-1-1',
'after:absolute',
'after:-top-1',
'after:grid',
'after:-left-1',
'after:w-7',
'after:h-7',
'hover:bg-dark/2',
'hover:text-current',
'dark:hover:bg-light/2',
'dark:hover:text-current',
isActive ? ['hover:bg-primary/4', 'dark:hover:bg-primary/4'] : [],
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleVisibility();
}}
>
<IconChevronRight
{hasDescendants ? (
<span
className={tcls(
'grid',
'flex-shrink-0',
'group',
'relative',
'rounded-full',
'w-5',
'h-5',
'p-0.5',
'[&>path]:transition-[stroke-opacity]',
'text-current',
'transition-transform',
'[&>path]:[stroke-opacity:0.40]',
'group-hover:[&>path]:[stroke-opacity:1]',
isVisible ? ['rotate-90'] : ['rotate-0'],
'after:grid-area-1-1',
'after:absolute',
'after:-top-1',
'after:grid',
'after:-left-1',
'after:w-7',
'after:h-7',
'hover:bg-dark/2',
'hover:text-current',
'dark:hover:bg-light/2',
'dark:hover:text-current',
isActive ? ['hover:bg-primary/4', 'dark:hover:bg-primary/4'] : [],
)}
/>
</span>
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setIsVisible((prev) => !prev);
}}
>
<IconChevronRight
className={tcls(
'grid',
'flex-shrink-0',
'w-5',
'h-5',
'p-0.5',
'[&>path]:transition-[stroke-opacity]',
'text-current',
'transition-transform',
'[&>path]:[stroke-opacity:0.40]',
'group-hover:[&>path]:[stroke-opacity:1]',
isVisible ? ['rotate-90'] : ['rotate-0'],
)}
/>
</span>
) : null}
</Link>
{/* TODO: fix recursive issue */}
<motion.div
ref={scope}
className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')}
initial={isVisible ? show : hide}
>
{descendants}
</motion.div>
{hasDescendants ? (
<motion.div
ref={scope}
className={tcls(isVisible ? null : '[&_ul>li]:opacity-1')}
initial={isVisible ? show : hide}
>
{descendants}
</motion.div>
) : null}
</div>
);
}
+9 -7
View File
@@ -6,14 +6,16 @@ import { ClassValue } from '@/lib/tailwind';
export const HEADER_HEIGHT_DESKTOP = 64 as const;
/**
* Maximum width of the normal mode.
* Style for the container to adapt between normal and full width.
*/
export const CONTAINER_MAX_WIDTH_NORMAL = 'max-w-screen-2xl';
/**
* Padding of the container.
*/
export const CONTAINER_PADDING: ClassValue = ['px-4', 'sm:px-6', 'md:px-8'];
export const CONTAINER_STYLE: ClassValue = [
'px-4',
'sm:px-6',
'md:px-8',
'max-w-screen-2xl',
'mx-auto',
'page-full-width:max-w-full',
];
/**
* Height of the page cover.
+73
View File
@@ -0,0 +1,73 @@
import { ClassValue, tcls } from '@/lib/tailwind';
/**
* Placeholder to be used when a content is not yet loaded (in a React.Suspense boundary).
* It's used when streaming the content of a page.
*/
export function SkeletonParagraph(props: { style?: ClassValue }) {
const { style } = props;
return (
<div role="status" className={tcls('animate-pulse', style)}>
<div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-full mb-4"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%] mb-2.5"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 mb-2.5"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%] mb-2.5"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[80%] mb-2.5"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[70%]"></div>
<span className="sr-only">Loading...</span>
</div>
);
}
/**
* Placeholder when loading a title.
*/
export function SkeletonHeading(props: { style?: ClassValue }) {
const { style } = props;
return (
<div role="status" className={tcls('animate-pulse', style)}>
<div className="h-6 bg-gray-200 rounded-full dark:bg-gray-700 w-[50%] mb-4"></div>
<span className="sr-only">Loading...</span>
</div>
);
}
/**
* Placeholder when loading an asset (image, video, etc.)
*/
export function SkeletonImage(props: { style?: ClassValue }) {
const { style } = props;
return (
<div role="status" className={tcls('animate-pulse', style)}>
<div className="flex items-center justify-center w-full h-48 bg-gray-300 rounded dark:bg-gray-700">
<svg
className="w-10 h-10 text-gray-200 dark:text-gray-600"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 18"
>
<path d="M18 0H2a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2Zm-5.5 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm4.376 10.481A1 1 0 0 1 16 15H4a1 1 0 0 1-.895-1.447l3.5-7A1 1 0 0 1 7.468 6a.965.965 0 0 1 .9.5l2.775 4.757 1.546-1.887a1 1 0 0 1 1.618.1l2.541 4a1 1 0 0 1 .028 1.011Z" />
</svg>
</div>
</div>
);
}
/**
* Placeholder when loading a card
*/
export function SkeletonCard(props: { style?: ClassValue }) {
const { style } = props;
return (
<div
role="status"
className={tcls('animate-pulse', 'p-4 border border-gray-200', 'rounded', style)}
>
<div className="h-2.5 bg-gray-200 rounded-full dark:bg-gray-700 w-48 mb-4"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700 max-w-[360px] mb-2.5"></div>
<div className="h-2 bg-gray-200 rounded-full dark:bg-gray-700"></div>
<span className="sr-only">Loading...</span>
</div>
);
}
+1
View File
@@ -2,3 +2,4 @@ export * from './Checkbox';
export * from './Button';
export * from './Loading';
export * from './Card';
export * from './Skeleton';
-1
View File
@@ -1 +0,0 @@
export * from './useIsLoadingPage';
-37
View File
@@ -1,37 +0,0 @@
import { atom, useRecoilValue } from 'recoil';
const loadingPageAtom = atom({
key: 'loadingPage',
default: false,
effects: [
({ setSelf }) => {
// @ts-ignore
if (typeof window === 'undefined' || !window.navigation) {
return;
}
// @ts-ignore
window.navigation.addEventListener('navigate', () => {
// Next.js finished fetching the page and update the URL.
setSelf(false);
});
document.addEventListener('click', (event) => {
// @ts-ignore
if (!event.target || event.target.tagName !== 'A') {
return;
}
// CLick on a <Link> component.
setSelf(true);
});
},
],
});
/**
* Return true if we are loading a new page.
*/
export function useIsLoadingPage(): boolean {
return useRecoilValue(loadingPageAtom);
}
+3 -3
View File
@@ -20,7 +20,7 @@ export interface ContentRefContext extends PageHrefContext {
content: ContentPointer;
space: Space;
pages: Revision['pages'];
page: RevisionPageDocument;
page?: RevisionPageDocument;
}
/**
@@ -54,7 +54,7 @@ export async function resolveContentRef(
(contentRef.space ?? space.id) === space.id
) {
const page =
!contentRef.page || contentRef.page === activePage.id
!contentRef.page || contentRef.page === activePage?.id
? activePage
: resolvePageId(pages, contentRef.page)?.page;
if (!page) {
@@ -65,7 +65,7 @@ export async function resolveContentRef(
return {
href: pageHref(pages, page, linksContext),
text: page.title,
active: page.id === activePage.id,
active: page.id === activePage?.id,
};
}
+1
View File
@@ -141,6 +141,7 @@ const config: Config = {
plugin(function ({ addVariant }) {
addVariant('navigation-open', 'body.navigation-open &');
addVariant('search-open', 'body.search-open &');
addVariant('page-full-width', 'body.page-full-width &');
}),
containerQueries,
],