diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 463e92a4b..221f4d5df 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { CollapsibleTrigger, } from './ui/collapsible'; import { Button } from './ui/button'; +import TableOfContents from './TableOfContents'; const DocLink = ({ uri, @@ -276,9 +277,9 @@ export const SidebarLayout = ({ children }: { children: React.ReactNode }) => { -
+
{!shouldHideSidebar && ( -
+
{
)}
{children}
+ {!shouldHideSidebar && ( +
+ +
+ )}
diff --git a/components/TableOfContents.tsx b/components/TableOfContents.tsx new file mode 100644 index 000000000..d084e44f8 --- /dev/null +++ b/components/TableOfContents.tsx @@ -0,0 +1,210 @@ +'use client'; + +import React, { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import { cn } from '~/lib/utils'; + +interface TocItem { + id: string; + text: string; + level: number; +} + +interface TableOfContentsProps { + className?: string; +} + +export const TableOfContents: React.FC = ({ + className, +}) => { + const router = useRouter(); + const [tocItems, setTocItems] = useState([]); + const [activeId, setActiveId] = useState(''); + + // Extract headings from the page + useEffect(() => { + const headings = document.querySelectorAll('h2, h3'); + const items: TocItem[] = []; + + // Skip the first heading and add "Introduction" as the first item + if (headings.length > 0) { + items.push({ + id: 'introduction', + text: 'Introduction', + level: 2, // Same level as h2 + }); + } + + // Start from index 1 to skip the first heading + for (let i = 1; i < headings.length; i++) { + // Get the heading element and its text content + const heading = headings[i]; + // Get the text content of the heading + const text = heading.textContent || ''; + // Get the ID of the heading, or generate one from the text content + const id = heading.id || text.toLowerCase().replace(/\s+/g, '-'); + + // If the heading doesn't have an ID, set one + if (!heading.id && id) { + heading.id = id; + } + // Add the heading to the table of contents + items.push({ + id, + text, + level: parseInt(heading.tagName.substring(1), 10), // Get heading level (2 for h2, 3 for h3, etc.) + }); + } + + setTocItems(items); + }, [router.asPath]); + + // Intersection Observer to track which section is visible + useEffect(() => { + if (tocItems.length === 0) return; + + const observer = new IntersectionObserver( + // Callback function to handle intersection events + (entries) => { + // Track the currently active section + let newActiveId = ''; + + // Check if we are at the top of the page + const isAtTop = window.scrollY < 100; // 100px from top + + // If at the top, highlight Introduction + if (isAtTop) { + newActiveId = 'introduction'; + } else { + // Otherwise, find the first visible heading + entries.forEach((entry) => { + if (entry.isIntersecting && !newActiveId) { + newActiveId = entry.target.id; + } + }); + } + + // Update the active ID + if (newActiveId) { + setActiveId(newActiveId); + } + }, + { + rootMargin: '-20% 0px -60% 0px', + threshold: 0.1, + }, + ); + + // Observe all headings + tocItems.forEach(({ id }) => { + const element = document.getElementById(id); + if (element) { + // Observe the element + observer.observe(element); + } + }); + + return () => { + tocItems.forEach(({ id }) => { + const element = document.getElementById(id); + if (element) { + // Unobserve the element + observer.unobserve(element); + } + }); + }; + }, [tocItems]); + + useEffect(() => { + const handleScroll = () => { + if (window.scrollY < 100) { + // If at the top, highlight Introduction + setActiveId('introduction'); + } + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + const handleClick = useCallback( + // Callback function to handle click events + (e: React.MouseEvent, id: string) => { + e.preventDefault(); + // Get the element to scroll to + const element = + id === 'introduction' + ? document.documentElement // Scroll to top for introduction + : document.getElementById(id); + + if (element) { + // Calculate the scroll position + const yOffset = -80; // Adjust this value to match your header height + const y = + id === 'introduction' + ? 0 + : element.getBoundingClientRect().top + + window.pageYOffset + + yOffset; + + // Scroll to the element + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }, + [], + ); + + if (tocItems.length === 0) { + return null; + } + + return ( + + ); +}; + +export default TableOfContents; diff --git a/pages/learn/[slug].page.tsx b/pages/learn/[slug].page.tsx index d186f1a4d..c60723ed4 100644 --- a/pages/learn/[slug].page.tsx +++ b/pages/learn/[slug].page.tsx @@ -1,5 +1,6 @@ import React from 'react'; import Head from 'next/head'; +import { useRouter } from 'next/router'; import StyledMarkdown from '~/components/StyledMarkdown'; import { getLayout } from '~/components/Sidebar'; import getStaticMarkdownPaths from '~/lib/getStaticMarkdownPaths'; @@ -23,6 +24,7 @@ export default function StaticMarkdownPage({ frontmatter: any; content: any; }) { + const router = useRouter(); const fileRenderType = '_md'; const newTitle = 'JSON Schema - ' + frontmatter.title; return ( @@ -31,7 +33,7 @@ export default function StaticMarkdownPage({ {newTitle} {frontmatter.title} - + {newTitle} {frontmatter.title} - + {newTitle} {frontmatter.title || 'NO TITLE!'} - + {newTitle} {frontmatter.title || 'NO TITLE!'} - +