toc.tsx148 lines · main
| 1 | 'use client' |
| 2 | |
| 3 | import { Fragment, useRef, type ComponentProps, type HTMLAttributes, type ReactNode } from 'react' |
| 4 | import { cn, ScrollArea, ScrollViewport } from 'ui' |
| 5 | import { removeAnchor } from 'ui/src/components/CustomHTMLElements/CustomHTMLElements.utils' |
| 6 | |
| 7 | import type { TOCItemType } from './server/get-toc' |
| 8 | import * as Primitive from './toc-primitive' |
| 9 | import { TocThumb } from './toc-thumb' |
| 10 | |
| 11 | export interface TOCProps { |
| 12 | /** |
| 13 | * Custom content in TOC container, before the main TOC |
| 14 | */ |
| 15 | header?: ReactNode |
| 16 | /** |
| 17 | * Custom content in TOC container, after the main TOC |
| 18 | */ |
| 19 | footer?: ReactNode |
| 20 | children: ReactNode |
| 21 | } |
| 22 | |
| 23 | export function Toc(props: HTMLAttributes<HTMLDivElement>) { |
| 24 | return ( |
| 25 | <div |
| 26 | id="toc" |
| 27 | {...props} |
| 28 | className={cn('sticky top-(--header-height) h-fit max-md:hidden', props.className)} |
| 29 | style={ |
| 30 | { |
| 31 | ...props.style, |
| 32 | } as object |
| 33 | } |
| 34 | > |
| 35 | <div className="flex h-fit w-(--toc-width) max-w-full flex-col gap-3 pe-4"> |
| 36 | {props.children} |
| 37 | </div> |
| 38 | </div> |
| 39 | ) |
| 40 | } |
| 41 | |
| 42 | export function TOCScrollArea({ |
| 43 | isMenu, |
| 44 | ...props |
| 45 | }: ComponentProps<typeof ScrollArea> & { isMenu?: boolean }) { |
| 46 | const viewRef = useRef<HTMLDivElement>(null) |
| 47 | |
| 48 | return ( |
| 49 | <ScrollArea {...props} className={cn('flex flex-col ps-px', props.className)}> |
| 50 | <Primitive.ScrollProvider containerRef={viewRef}> |
| 51 | <ScrollViewport |
| 52 | className={cn('relative min-h-0 text-sm', isMenu && 'mt-2 mb-4 mx-4 md:mx-6')} |
| 53 | ref={viewRef} |
| 54 | > |
| 55 | {props.children} |
| 56 | </ScrollViewport> |
| 57 | </Primitive.ScrollProvider> |
| 58 | </ScrollArea> |
| 59 | ) |
| 60 | } |
| 61 | |
| 62 | export function TOCItems({ |
| 63 | items, |
| 64 | showTrack = false, |
| 65 | }: { |
| 66 | items: TOCItemType[] |
| 67 | showTrack?: boolean |
| 68 | }) { |
| 69 | const containerRef = useRef<HTMLDivElement>(null) |
| 70 | |
| 71 | if (items.length === 0) return null |
| 72 | |
| 73 | return ( |
| 74 | <> |
| 75 | <TocThumb |
| 76 | containerRef={containerRef} |
| 77 | className="absolute inset-s-0 mt-(--toc-top) h-(--toc-height) w-px bg-foreground transition-all" |
| 78 | /> |
| 79 | <div |
| 80 | ref={containerRef} |
| 81 | className={cn( |
| 82 | 'list-none text-[0.8rem] flex flex-col pl-[calc(0.75rem+5px)] border-foreground/10', |
| 83 | showTrack && 'border-s' |
| 84 | )} |
| 85 | > |
| 86 | {items.map((item) => ( |
| 87 | <TOCItem key={item.url} item={item} /> |
| 88 | ))} |
| 89 | </div> |
| 90 | </> |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | const formatSlug = (slug: string) => { |
| 95 | // [Joshen] We will still provide support for headers declared like this: |
| 96 | // ## REST API {#rest-api-overview} |
| 97 | // At least for now, this was a docusaurus thing. |
| 98 | if (slug.includes('#')) return slug.split('#')[1] |
| 99 | return slug |
| 100 | } |
| 101 | |
| 102 | function formatTOCHeader(content: string) { |
| 103 | let insideInlineCode = false |
| 104 | const res: Array<{ type: 'text'; value: string } | { type: 'code'; value: string }> = [] |
| 105 | |
| 106 | for (const x of content) { |
| 107 | if (x === '`') { |
| 108 | if (!insideInlineCode) { |
| 109 | insideInlineCode = true |
| 110 | res.push({ type: 'code', value: '' }) |
| 111 | } else { |
| 112 | insideInlineCode = false |
| 113 | } |
| 114 | } else { |
| 115 | if (insideInlineCode) { |
| 116 | res[res.length - 1].value += x |
| 117 | } else { |
| 118 | if (res.length === 0 || res[res.length - 1].type === 'code') { |
| 119 | res.push({ type: 'text', value: x }) |
| 120 | } else { |
| 121 | res[res.length - 1].value += x |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | return res |
| 128 | } |
| 129 | |
| 130 | function TOCItem({ item }: { item: TOCItemType }) { |
| 131 | return ( |
| 132 | <Primitive.TOCItem |
| 133 | href={`#${formatSlug(item.url)}`} |
| 134 | className={cn( |
| 135 | 'text-foreground-lighter hover:text-brand-link transition-colors py-1 wrap-anywhere first:pt-0 last:pb-0 data-[active=true]:text-foreground', |
| 136 | item.depth <= 2 && 'ps-3', |
| 137 | item.depth === 3 && 'ps-6', |
| 138 | item.depth >= 4 && 'ps-8' |
| 139 | )} |
| 140 | > |
| 141 | {formatTOCHeader(removeAnchor(item.title)).map((x, index) => ( |
| 142 | <Fragment key={index}> |
| 143 | {x.type === 'code' ? <code className="text-code-inline">{x.value}</code> : x.value} |
| 144 | </Fragment> |
| 145 | ))} |
| 146 | </Primitive.TOCItem> |
| 147 | ) |
| 148 | } |