UI: Support enabling Clerk-style TOC

This commit is contained in:
fuma-nama
2024-08-16 18:05:39 +08:00
parent ad7e82de46
commit 32ca37aaae
6 changed files with 105 additions and 95 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-ui': minor
---
Support Clerk-style TOC
+4 -8
View File
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { Card, Cards } from 'fumadocs-ui/components/card';
import { DocsPage, DocsBody } from 'fumadocs-ui/page';
import { notFound } from 'next/navigation';
import { type ReactNode } from 'react';
import { Fragment } from 'react';
import { utils, type Page } from '@/utils/source';
import { createMetadata } from '@/utils/metadata';
import Preview from '@/components/preview';
@@ -51,6 +51,7 @@ export default function Page({
full={page.data.full}
tableOfContent={{
footer,
style: 'clerk',
}}
tableOfContentPopover={{ footer }}
>
@@ -67,13 +68,8 @@ export default function Page({
<page.data.exports.default
components={{
HeadlessOnly:
params.slug[0] === 'headless'
? ({ children }: { children: ReactNode }) => children
: () => undefined,
UIOnly:
params.slug[0] === 'ui'
? ({ children }: { children: ReactNode }) => children
: () => undefined,
params.slug[0] === 'headless' ? Fragment : () => undefined,
UIOnly: params.slug[0] === 'ui' ? Fragment : () => undefined,
}}
/>
{page.data.index ? <Category page={page} /> : null}
+12 -35
View File
@@ -1,12 +1,13 @@
'use client';
import type { TOCItemType } from 'fumadocs-core/server';
import * as Primitive from 'fumadocs-core/toc';
import { useLayoutEffect, useRef, useState } from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import type { PosType } from '@/components/layout/toc';
import { useTocThumb } from '@/utils/use-toc-thumb';
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
export function ClerkTOCItems({
export default function ClerkTOCItems({
items,
isMenu = false,
}: {
@@ -15,37 +16,13 @@ export function ClerkTOCItems({
}): React.ReactElement {
const { text } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<PosType>([0, 0]);
const active = Primitive.useActiveAnchors();
const pos = useTocThumb(containerRef);
const [svg, setSvg] = useState<{
path: string;
width: number;
height: number;
}>();
useLayoutEffect(() => {
const container = containerRef.current;
if (active.length === 0 || !container || container.clientHeight === 0) {
setPos([0, 0]);
return;
}
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${item}"]`,
);
if (!element) continue;
upper = Math.min(upper, element.offsetTop);
lower = Math.max(lower, element.offsetTop + element.clientHeight);
}
setPos([upper, lower - upper]);
}, [active]);
useLayoutEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
@@ -56,18 +33,18 @@ export function ClerkTOCItems({
h = 0;
const d: string[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const element: HTMLElement | null = container.querySelector(
`a[href="#${item.url.slice(1)}"]`,
`a[href="#${items[i].url.slice(1)}"]`,
);
if (!element) continue;
const offset = getLineOffset(item.depth) + 1,
top = i === 0 ? element.offsetTop : element.offsetTop + 8,
const styles = getComputedStyle(element);
const offset = getLineOffset(items[i].depth) + 1,
top = element.offsetTop + parseFloat(styles.paddingTop),
bottom =
i === items.length - 1
? element.offsetTop + element.clientHeight
: element.offsetTop + element.clientHeight - 8;
element.offsetTop +
element.clientHeight -
parseFloat(styles.paddingBottom);
w = Math.max(offset, w);
h = Math.max(h, bottom);
@@ -172,7 +149,7 @@ function TOCItem({
style={{
paddingInlineStart: `${getItemOffset(item.depth)}px`,
}}
className="relative py-2 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary"
className="relative py-2 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary"
>
{offset !== getLineOffset(upper) ? (
<svg
+17 -45
View File
@@ -1,14 +1,7 @@
import { ChevronRight, Text } from 'lucide-react';
import type { TOCItemType } from 'fumadocs-core/server';
import * as Primitive from 'fumadocs-core/toc';
import {
type ReactElement,
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { type ReactElement, type ReactNode, useMemo, useRef } from 'react';
import { cn } from '@/utils/cn';
import { useI18n } from '@/contexts/i18n';
import {
@@ -16,27 +9,26 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ClerkTOCItems } from '@/components/layout/toc-clerk';
import { useTocThumb } from '@/utils/use-toc-thumb';
import { ScrollArea, ScrollViewport } from '../ui/scroll-area';
export type PosType = [top: number, height: number];
export interface TOCProps {
items: TOCItemType[];
/**
* Custom content in TOC container, before the main TOC
*/
header?: ReactNode;
/**
* Custom content in TOC container, after the main TOC
*/
footer?: ReactNode;
children: ReactNode;
}
export const TocProvider = Primitive.AnchorProvider;
export function Toc({ items, header, footer }: TOCProps): ReactElement {
export function Toc({ header, footer, children }: TOCProps): ReactElement {
const { text } = useI18n();
return (
@@ -49,13 +41,18 @@ export function Toc({ items, header, footer }: TOCProps): ReactElement {
<Text className="size-4" />
{text.toc}
</h3>
<ClerkTOCItems items={items} />
{children}
{footer}
</div>
);
}
export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
export function TocPopover({
header,
footer,
items,
children,
}: TOCProps & { items: TOCItemType[] }): ReactElement {
const { text } = useI18n();
const active = Primitive.useActiveAnchor();
const current = useMemo(() => {
@@ -83,15 +80,14 @@ export function TocPopover({ items, header, footer }: TOCProps): ReactElement {
data-toc-popover=""
>
{header}
<ClerkTOCItems items={items} isMenu />
{children}
{footer}
</PopoverContent>
</Popover>
);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- test
function TOCItems({
export function TOCItems({
items,
isMenu = false,
}: {
@@ -100,31 +96,7 @@ function TOCItems({
}): React.ReactElement {
const { text } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<PosType>([0, 0]);
const active = Primitive.useActiveAnchors();
useLayoutEffect(() => {
const container = containerRef.current;
if (active.length === 0 || !container || container.clientHeight === 0) {
setPos([0, 0]);
return;
}
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${item}"]`,
);
if (!element) continue;
upper = Math.min(upper, element.offsetTop);
lower = Math.max(lower, element.offsetTop + element.clientHeight);
}
setPos([upper, lower - upper]);
}, [active]);
const pos = useTocThumb(containerRef);
if (items.length === 0)
return (
@@ -166,7 +138,7 @@ function TOCItem({ item }: { item: TOCItemType }): React.ReactElement {
<Primitive.TOCItem
href={item.url}
className={cn(
'py-1.5 transition-colors [overflow-wrap:anywhere] data-[active=true]:text-fd-primary',
'py-1.5 transition-colors [overflow-wrap:anywhere] first:pt-0 last:pb-0 data-[active=true]:text-fd-primary',
item.depth <= 2 && 'ps-3.5',
item.depth === 3 && 'ps-6',
item.depth >= 4 && 'ps-8',
+24 -7
View File
@@ -1,5 +1,6 @@
import { type TableOfContents } from 'fumadocs-core/server';
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { replaceOrDefault } from './utils/shared';
import { cn } from './utils/cn';
import type { BreadcrumbProps, FooterProps, TOCProps } from './page.client';
@@ -9,13 +10,21 @@ declare const {
TocPopover,
Breadcrumb,
Footer,
TOCItems,
TocProvider,
LastUpdate,
}: typeof import('./page.client');
type TableOfContentOptions = Omit<TOCProps, 'items'> & {
const ClerkTOCItems = dynamic(() => import('@/components/layout/toc-clerk'));
type TableOfContentOptions = Omit<TOCProps, 'items' | 'children'> & {
enabled: boolean;
component: ReactNode;
/**
* @defaultValue 'normal'
*/
style?: 'normal' | 'clerk';
};
interface BreadcrumbOptions extends BreadcrumbProps {
@@ -95,7 +104,13 @@ export function DocsPage({
items={toc}
header={tocPopoverOptions.header}
footer={tocPopoverOptions.footer}
/>
>
{tocPopoverOptions.style === 'clerk' ? (
<ClerkTOCItems items={toc} isMenu />
) : (
<TOCItems items={toc} isMenu />
)}
</TocPopover>
</div>,
)}
<article className="flex flex-1 flex-col gap-6 px-4 pt-10 md:px-6 md:pt-12">
@@ -108,11 +123,13 @@ export function DocsPage({
</div>
{replaceOrDefault(
tocOptions,
<Toc
items={toc}
header={tocOptions.header}
footer={tocOptions.footer}
/>,
<Toc header={tocOptions.header} footer={tocOptions.footer}>
{tocOptions.style === 'clerk' ? (
<ClerkTOCItems items={toc} />
) : (
<TOCItems items={toc} />
)}
</Toc>,
)}
</TocProvider>
);
+43
View File
@@ -0,0 +1,43 @@
import { RefObject, useLayoutEffect, useState } from 'react';
import * as Primitive from 'fumadocs-core/toc';
export type TOCThumb = [top: number, height: number];
export function useTocThumb(containerRef: RefObject<HTMLElement>): TOCThumb {
const active = Primitive.useActiveAnchors();
const [pos, setPos] = useState<TOCThumb>([0, 0]);
useLayoutEffect(() => {
const container = containerRef.current;
if (active.length === 0 || !container || container.clientHeight === 0) {
setPos([0, 0]);
return;
}
let upper = Number.MAX_VALUE,
lower = 0;
for (const item of active) {
const element: HTMLElement | null = container.querySelector(
`a[href="#${item}"]`,
);
if (!element) continue;
const styles = getComputedStyle(element);
upper = Math.min(
upper,
element.offsetTop + parseFloat(styles.paddingTop),
);
lower = Math.max(
lower,
element.offsetTop +
element.clientHeight -
parseFloat(styles.paddingBottom),
);
}
setPos([upper, lower - upper]);
}, [active, containerRef]);
return pos;
}