TOC.tsx 2.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. 'use client';
  2. import * as React from 'react';
  3. import { TableOfContents } from '@/lib/toc';
  4. import useMounted from '@/hooks/use-mounted';
  5. import clsx from 'clsx';
  6. interface TocProps {
  7. toc: TableOfContents;
  8. }
  9. export default function TOC({ toc }: TocProps) {
  10. const itemIds = React.useMemo(
  11. () =>
  12. toc.items
  13. ? toc.items
  14. .flatMap((item) => [item.url, item?.items?.map((item) => item.url)])
  15. .flat()
  16. .filter(Boolean)
  17. .map((id) => id?.split('#')[1])
  18. : [],
  19. [toc],
  20. );
  21. const activeHeading = useActiveItem(itemIds);
  22. const mounted = useMounted();
  23. if (!toc?.items) {
  24. return null;
  25. }
  26. return mounted ? (
  27. <Tree tree={toc} activeItem={activeHeading} />
  28. ) : null;
  29. }
  30. function useActiveItem(itemIds: (string | undefined)[]) {
  31. const [activeId, setActiveId] = React.useState<string>('');
  32. React.useEffect(() => {
  33. const observer = new IntersectionObserver(
  34. (entries) => {
  35. entries.forEach((entry) => {
  36. if (entry.isIntersecting) {
  37. setActiveId(entry.target.id);
  38. }
  39. });
  40. },
  41. { rootMargin: '0% 0% -80% 0%' },
  42. );
  43. itemIds?.forEach((id) => {
  44. if (!id) {
  45. return;
  46. }
  47. const element = document.getElementById(id);
  48. if (element) {
  49. observer.observe(element);
  50. }
  51. });
  52. return () => {
  53. itemIds?.forEach((id) => {
  54. if (!id) {
  55. return;
  56. }
  57. const element = document.getElementById(id);
  58. if (element) {
  59. observer.unobserve(element);
  60. }
  61. });
  62. };
  63. }, [itemIds]);
  64. return activeId;
  65. }
  66. interface TreeProps {
  67. tree: TableOfContents;
  68. level?: number;
  69. activeItem?: string | null;
  70. }
  71. function Tree({ tree, level = 1, activeItem }: TreeProps) {
  72. return tree?.items?.length && level < 3 ? (
  73. <ul className={clsx('p-0 list-unstyled space-y-2', { 'pl-4': level !== 1 })}>
  74. {tree.items.map((item, index) => {
  75. return (
  76. <li key={index}>
  77. <a href={item.url} className={clsx('link-muted', item.url === `#${activeItem}` ? 'font-medium text-headers' : '')}>
  78. {item.title}
  79. </a>
  80. {item.items?.length ? <Tree tree={item} level={level + 1} activeItem={activeItem} /> : null}
  81. </li>
  82. );
  83. })}
  84. </ul>
  85. ) : null;
  86. }