Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
321 changes: 165 additions & 156 deletions components/TableContents.tsx
Original file line number Diff line number Diff line change
@@ -1,222 +1,231 @@
"use client";

import React, { useState, useEffect, useRef } from "react";
import { sanitizeStringForURL } from "../utils/sanitizeStringForUrl";

/* ---------------- Types ---------------- */

type HeadingItem = {
id: string;
title: string;
type: "h1" | "h2" | "h3" | "h4";
};

type TOCProps = {
headings: HeadingItem[];
isList: boolean;
setIsList: React.Dispatch<React.SetStateAction<boolean>>;
};

type TOCItemProps = {
id: string;
title: string;
type: HeadingItem["type"];
activeId: string;
onClick: (id: string) => void;
};

/* ---------------- TOC Item ---------------- */

function TOCItem({
id,
title,
type,
onClick,
}: {
id: string;
title: string;
type: string;
onClick: (id: string) => void;
}) {
const itemClasses = "mb-1 text-slate-600 space-y-1";
activeId,
}: TOCItemProps) {
let marginLeft = "2rem";

// Calculate margin left based on heading type
let marginLeft;
switch (type) {
case "h1":
marginLeft = 0;
marginLeft = "0";
break;
case "h2":
marginLeft = "1rem"; // Adjust as needed
marginLeft = "1rem";
break;
case "h3":
marginLeft = "1.5rem"; // Adjust as needed
break;
case "h4":
marginLeft = "2rem"; // Adjust as needed
marginLeft = "1.5rem";
break;
default:
marginLeft = "2rem"; // Default to h4 margin
}

const isActive = activeId === id;

return (
<li className={itemClasses} style={{ marginLeft }}>
<li data-toc-id={id} style={{ marginLeft }}>
<button
onClick={() => onClick(id)}
className="block w-full py-1 text-sm text-left text-black transition-all duration-150 ease-in-out rounded-md opacity-75 hover:text-orange-500 hover:opacity-100"
className={`block w-full py-1 text-sm text-left transition-all duration-150 rounded-md ${
isActive
? "text-orange-500 font-medium opacity-100"
: "text-black opacity-75 hover:text-orange-500 hover:opacity-100"
}`}
>
{title}
</button>
</li>
);
}

export default function TOC({ headings, isList, setIsList }) {
const tocRef = useRef(null);
/* ---------------- TOC ---------------- */

export default function TOC({ headings, isList, setIsList }: TOCProps) {
const tocContainerRef = useRef<HTMLDivElement | null>(null);

const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [activeId, setActiveId] = useState("");
const [isSmallScreen, setIsSmallScreen] = useState(false);

/* ---------------- Scroll Spy ---------------- */
useEffect(() => {
if (!tocRef.current) return;
if (!headings.length) return;

const observer = new IntersectionObserver(
(entries) => {
const visible = entries.find((e) => e.isIntersecting);
if (visible) {
setActiveId(visible.target.id);
}
},
{ rootMargin: "-40% 0px -40% 0px", threshold: 0.1 }
);

headings.forEach((h) => {
const id = sanitizeStringForURL(h.id, true);
const el = document.getElementById(id);
if (el) observer.observe(el);
});

return () => observer.disconnect();
}, [headings]);

const container = tocRef.current;
/* ---------------- Auto-switch list mode ---------------- */
useEffect(() => {
if (!tocContainerRef.current) return;

function resizeHandler() {
setIsList(container.clientHeight > window.innerHeight * 0.8);
}
const resizeHandler = () => {
setIsList(
tocContainerRef.current!.clientHeight >
window.innerHeight * 0.8
);
};

resizeHandler()
window.addEventListener("resize", resizeHandler)
resizeHandler();
window.addEventListener("resize", resizeHandler);
return () => window.removeEventListener("resize", resizeHandler);
}, [setIsList]);

return () => { window.removeEventListener("resize", resizeHandler) }
/* ---------------- Auto-scroll TOC ---------------- */
useEffect(() => {
if (!activeId || !tocContainerRef.current) return;

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const activeEl = tocContainerRef.current.querySelector(
`[data-toc-id="${activeId}"]`
) as HTMLElement | null;

activeEl?.scrollIntoView({ block: "nearest", behavior: "smooth" });
}, [activeId]);

/* ---------------- Scroll on click ---------------- */
const handleItemClick = (id: string) => {
const sanitizedId = sanitizeStringForURL(id, true);
const element = document.getElementById(sanitizedId);
if (!element) return;

const handleItemClick = (id) => {
const sanitizedId = sanitizeStringForURL(id, true);
const element = document.getElementById(sanitizedId);
if (element) {
const offset = 80;
const offsetPosition = element.offsetTop - offset;
window.scrollTo({
top: offsetPosition,
top: element.offsetTop - 80,
behavior: "smooth",
});

window.history.replaceState(null, null, `#${sanitizedId}`);
}
};

// State to track screen width
const [isSmallScreen, setIsSmallScreen] = useState(false);

// Function to check if screen width is small
const checkScreenSize = () => {
setIsSmallScreen(window.innerWidth < 1024); // Adjust breakpoint as needed
window.history.replaceState(null, "", `#${sanitizedId}`);
setActiveId(sanitizedId);
};

/* ---------------- Screen size ---------------- */
useEffect(() => {
checkScreenSize(); // Initial check
window.addEventListener("resize", checkScreenSize); // Event listener for screen resize
return () => {
window.removeEventListener("resize", checkScreenSize); // Cleanup on component unmount
};
const resize = () => setIsSmallScreen(window.innerWidth < 1024);
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);

// Render dropdown if on a small screen, otherwise render regular TOC
return isSmallScreen ? (
<>
<div className="w-full max-w-[700px] px-4 mx-auto top-20">
<div className="flex items-center justify-center text-center w-full">
<button
onClick={() => setIsDropdownOpen((prev) => !prev)}
className="text-gray-700 focus:outline-none flex items-center justify-between w-full px-4 py-2 bg-white border border-gray-300 rounded-md shadow-sm hover:border-gray-400 focus:shadow-outline gap-2 text-center"
aria-expanded={isDropdownOpen}
aria-controls="toc-dropdown"
>
<span className="text-lg font-semibold text-left flex-grow">
Table of Contents
</span>
<span className="text-sm">{isDropdownOpen ? "▲" : "▼"}</span>
</button>
</div>
/* ---------------- Mobile ---------------- */
if (isSmallScreen) {
return (
<div className="w-full max-w-[700px] px-4 mx-auto">
<button
onClick={() => setIsDropdownOpen((p) => !p)}
className="w-full px-4 py-2 bg-white border rounded-md flex justify-between"
>
<span className="font-semibold">Table of Contents</span>
<span>{isDropdownOpen ? "▲" : "▼"}</span>
</button>

{isDropdownOpen && (
<div className="mt-2 max-h-[300px] overflow-y-auto border rounded-md shadow-md p-2 bg-white w-full md:w-auto">
<ul className="space-y-1">
{headings.map((item, index) => {
let indent = "";
switch (item.type) {
case "h1":
indent = "ml-0";
break;
case "h2":
indent = "ml-4";
break;
case "h3":
indent = "ml-8";
break;
case "h4":
indent = "ml-12";
break;
default:
indent = "ml-0";
}

return (
<li
key={item.id}
className={`text-sm text-gray-700 hover:text-orange-500 ${indent}`}
<ul className="mt-2 space-y-1">
{headings.map((item) => {
const id = sanitizeStringForURL(item.id, true);
return (
<li key={id}>
<button
onClick={() => {
handleItemClick(item.id);
setIsDropdownOpen(false);
}}
className={`text-sm ${
activeId === id
? "text-orange-500 font-medium"
: "text-gray-700 hover:text-orange-500"
}`}
>
<button
onClick={() => {
const el = document.getElementById(item.id);
if (el) {
const offset = 80;
window.scrollTo({
top: el.offsetTop - offset,
behavior: "smooth",
});
const sanitizedId = sanitizeStringForURL(item.title, true);
window.history.replaceState(
null,
null,
`#${sanitizedId}`
);
setIsDropdownOpen(false);
}
}}
className="w-full text-left"
>
{item.title}
</button>
</li>
);
})}
</ul>
</div>
{item.title}
</button>
</li>
);
})}
</ul>
)}
</div>
</>
) : (
<>
<div className="left-0 inline-block p-4 lg:hidden top-20">
<div className="mb-2 text-lg font-semibold">Table of Contents</div>
);
}

/* ---------------- Desktop ---------------- */
return (
<div
ref={tocContainerRef}
className="hidden lg:inline-block sticky top-24 p-4 max-h-[80vh] overflow-y-auto"
>
<div className="mb-2 text-lg font-semibold">Table of Contents</div>

{isList ? (
<select
className="block w-full px-4 py-2 text-sm leading-tight bg-white border border-gray-300 rounded-md shadow-sm hover:border-gray-400 focus:outline-none focus:shadow-outline"
className="block w-full px-4 py-2 border rounded-md bg-white"
onChange={(e) => handleItemClick(e.target.value)}
>
{headings.map((item, index) => (
<option key={index} value={item.id}>
{headings.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</div>
<div className="hidden lg:inline-block left-0 top-20 bg-inherit p-4 sticky ">
<div className="mb-2 text-lg font-semibold">Table of Contents</div>
{isList ? (
<select
className="block w-full px-4 py-2 text-sm leading-tight bg-white border border-gray-300 rounded-md shadow-sm hover:border-gray-400 focus:outline-none focus:shadow-outline"
onChange={(e) => handleItemClick(e.target.value)}
>
{headings.map((item, index) => (
<option key={index} value={item.id}>
{item.title}
</option>
))}
</select>
) : (
<nav ref={tocRef}>
<ul className="pl-0 leading-5">
{headings.map((item, index) => (
) : (
<nav>
<ul className="space-y-1">
{headings.map((item) => {
const id = sanitizeStringForURL(item.id, true);
return (
<TOCItem
key={index}
id={item.id}
key={id}
id={id}
title={item.title}
type={item.type}
activeId={activeId}
onClick={handleItemClick}
/>
))}
</ul>
</nav>
)}
</div>
</>
);
})}
</ul>
</nav>
)}
</div>
);
}
Loading