Hoy decidí crear un TOC que todos tienen.
Actualmente, estoy mostrando artículos escritos en Markdown compilados con next-mdx-remote.
Con rehype-slug, ya se han asignado ID a todas las etiquetas de título, así que la mitad del trabajo está hecho.
Primero, vamos a crear un TOC de forma sencilla con useEffect y querySelectorAll.
1. Crear un TOC de forma sencilla

Cuando intentas algo por primera vez y no encuentras el camino, saber cómo hacer preguntas también es una habilidad.
Autodidactas como yo, sin nadie a quien pedir consejo, encontramos que recurrir a grandes mentores como Lütent, Claude o GPT es la forma más rápida.
Usé el código que obtuve de estos grandes mentores, con algunas modificaciones.
"use client";
import { useEffect, useState } from "react";
interface StringKeyValue {
[key: string]: string;
}
export default function TOC() {
const [headings, setHeadings] = useState<StringKeyValue[]>([]);
const [toc, setToc] = useState(false);
// Ejecuta una vez cuando el componente se carga.
useEffect(() => {
// Encuentra las etiquetas de nivel h2, h3 y h4 con querySelectorAll.
const hTags = document.querySelectorAll<HTMLHeadingElement>("h2, h3, h4");
// Convierte los elementos en una matriz.
const headings = [...hTags];
if (headings.length == 0) {
return;
}
setToc(true);
// Extrae solo el contenido necesario para crear el TOC.
const allHeadings = headings.map((heading) => ({
text: heading.innerText,
id: heading.id || heading.innerText.replace(/\s+/g, "-").toLowerCase(),
tagName: heading.tagName,
}));
setHeadings(allHeadings);
}, []);
return (
toc && (
// Calcula la posición del TOC para que esté justo al lado de la publicación.
<div className="fixed hidden xl:block top-40 w-64 p-4 left-[calc(50%+390px)]">
<div className="text-2xl mb-4">Contents</div>
<div className="pl-2">
<ul className="prose">
{headings.map((heading) => (
// Como rara vez llegaremos al nivel h4, solo se configuró la clase hasta h3.
<li
key={heading.id}
className={`${
heading.tagName == "H3" && "pl-6"
} border-l-2 border-gray-200`}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2`}
>
// Siempre se agrega un número como 1. 2. al título, así que se elimina con replace y
una expresión regular.
{heading.text.replace(/^\d+\.\s*/, "")}
</a>
</li>
))}
</ul>
</div>
</div>
)
);
}Y en globals.css, al agregar la opción de suavizado al scroll, se mejora el desplazamiento.
Como los desplazamientos bruscos no tienen mucho sentido, lo agregué globalmente como si fuera un tipo duro.
@layer base {
html {
scroll-behavior: smooth;
}
}Probé haciendo clic.
Funciona bien.

Ahora vamos a destacar las etiquetas que entren en el viewport utilizando un intersection observer.
2. Intersection Observer
Los parámetros que entran en la función son los siguientes.
const options = {
root: null, // Valor predeterminado es el viewport. Puede registrar un htmlelement como referencia.
rootMargin: '0px', // Configuración del margen del viewport.
threshold: 0.5 // Se llama al callback cuando el 50% entra en el viewport
};
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`${entry.target.id} está visible.`);
} else {
console.log('${entry.target.id} no está visible.');
}
});
}, options);
// Seleccionar elemento a observar
const target = document.querySelector('#target');
observer.observe(target);UsaRef para crear una variable observador y asígnala al intersection observer.
El código fue referenciado de el velog de ssoon-m.
export default function TOC() {
...
const observer = useRef<IntersectionObserver>();
const [activeToc, setActiveToc] = useState("");
useEffect(() => {
...
// Asignar intersection observer a observer.current
observer.current = new IntersectionObserver(
(entries) => {
// Registrar el id del elemento en interacción como activeToc
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
setActiveToc(entry.target.id);
});
},
{
// Observar desde el 70% por debajo del punto más alto hasta el más bajo
rootMargin: "0px 0px -70% 0px",
threshold: 0.8,
}
);
...
const allHeadings = headings.map((heading) => {
// Observar el elemento con intersection observer.
observer.current?.observe(heading);
...
});
...
// Llamar una función de limpieza al regresar una función anónima en return.
return () => observer.current?.disconnect();
}, []);
return (
toc && (
...
// Asignar clase para resaltar el título actual
{headings.map((heading) => (
<li
key={heading.id}
className={`${
heading.tagName === "H3" && "pl-6"
} ${activeToc == heading.id ? "border-l-4 border-gray-500":"border-l-2 border-gray-200"} `}
>
<a
href={`#${heading.id}`}
className={`block no-underline pl-2 ${
activeToc == heading.id ? "text-black font-black" : "text-gray-500"
}`}
>
...
);
}Luego de hacer esto, lo probé deslizándome mientras verificaba el TOC.
Responde bien tanto a los clics como al desplazamiento.

3. Conclusión
Quería crear un TOC con SSR, pero al ser reactivo, descubrí que es mejor generarlo en el lado del cliente para mi salud mental.
Ver TOCs en otros blogs me había dado ganas de implementarlo alguna vez, y ahora estoy feliz de haberlo logrado.
Lo siguiente será trabajar en introducir todos los posts en una base de datos.
댓글을 불러오는 중...