Análisis de Markdown en una tabla de contenido automatizada
Una tabla de contenido es una lista de enlaces que le permite saltar rápidamente a secciones específicas de contenido en la misma página. Beneficia el contenido de formato largo porque muestra al usuario una descripción general útil del contenido que hay con una forma conveniente de llegar allí.
Este tutorial le mostrará cómo analizar texto largo de Markdown en HTML y luego generar una lista de enlaces a partir de los encabezados. Después de eso, usaremos la API de Intersection Observer para descubrir qué sección está activa actualmente, agregaremos una animación de desplazamiento cuando se haga clic en un enlace y, finalmente, aprenderemos cómo Vue transition-group
nos permite crear una lista animada agradable dependiendo de qué sección esté. actualmente activo.
Análisis de rebajas
En la web, el contenido de texto suele entregarse en forma de Markdown. Si no lo ha usado, existen muchas razones por las que Markdown es una excelente opción para contenido de texto. Usaremos un analizador de rebajas llamado marcado, pero cualquier otro analizador también es bueno.
Obtendremos nuestro contenido de un archivo Markdown en GitHub. Después de cargar nuestro archivo Markdown, todo lo que tenemos que hacer es llamar a la marked(markdown, options)
función para analizar Markdown a HTML.
async function fetchAndParseMarkdown() { const url = 'https://gist.githubusercontent.com/lisilinhart/e9dcf5298adff7c2c2a4da9ce2a3db3f/raw/2f1a0d47eba64756c22460b5d2919d45d8118d42/red_panda.md' const response = await fetch(url) const data = await response.text() const htmlFromMarkdown = marked(data, { sanitize: true }); return htmlFromMarkdown}
Después de recuperar y analizar nuestros datos, pasaremos el HTML analizado a nuestro DOM reemplazando el contenido con innerHTML
.
async function init() { const $main = document.querySelector('#app'); const htmlContent = await fetchAndParseMarkdown(); $main.innerHTML = htmlContent}
init();
Generar una lista de enlaces de encabezado
Ahora que hemos generado el HTML, necesitamos transformar nuestros encabezados en una lista de enlaces en los que se puede hacer clic. Para encontrar los encabezados, usaremos la función DOM querySelectorAll('h1, h2')
, que selecciona todos h1
los h2
elementos dentro de nuestro contenedor de rebajas. Luego revisaremos los encabezados y extraeremos la información que necesitamos: el texto dentro de las etiquetas, la profundidad (que es 1 o 2) y el ID del elemento que podemos usar para vincular a cada encabezado respectivo.
function generateLinkMarkup($contentElement) { const headings = [...$contentElement.querySelectorAll('h1, h2')] const parsedHeadings = headings.map(heading = { return { title: heading.innerText, depth: heading.nodeName.replace(/D/g,''), id: heading.getAttribute('id') } }) console.log(parsedHeadings)}
Este fragmento da como resultado una serie de elementos similares a este:
[ {title: "The Red Panda", depth: "1", id: "the-red-panda"}, {title: "About", depth: "2", id: "about"}, // ... ]
Después de obtener la información que necesitamos de los elementos del encabezado, podemos usar literales de plantilla ES6 para generar los elementos HTML que necesitamos para la tabla de contenido.
Primero, recorremos todos los títulos y creamos li
elementos. Si estamos trabajando con h2
, depth: 2
agregaremos una clase de relleno adicional, .pl-4
para sangrarlos. De esa manera, podemos mostrar h2
elementos como subtítulos sangrados dentro de la lista de enlaces.
Finalmente, unimos la matriz de li
fragmentos y los envolvemos dentro de un ul
elemento.
function generateLinkMarkup($contentElement) { // ... const htmlMarkup = parsedHeadings.map(h = ` litoken interpolation"${h.depth 1 ? 'pl-4' : ''}" a href="#${h.id}"${h.title}/a /li `) const finalMarkup = `ul${htmlMarkup.join('')}/ul` return finalMarkup}
Eso es todo lo que necesitamos para generar nuestra lista de enlaces. Ahora agregaremos el HTML generado al DOM.
async function init() { const $main = document.querySelector('#content'); const $aside = document.querySelector('#aside'); const htmlContent = await fetchAndParseMarkdown(); $main.innerHTML = htmlContent const linkHtml = generateLinkMarkup($main); $aside.innerHTML = linkHtml }
Agregar un observador de intersección
A continuación, debemos averiguar qué parte del contenido estamos leyendo actualmente. Los observadores de intersección son la elección perfecta para esto. MDN define Intersection Observer de la siguiente manera:
La API Intersection Observer proporciona una forma de observar de forma asincrónica los cambios en la intersección de un elemento de destino con un elemento ancestro o con la ventana gráfica de un documento de nivel superior.
Básicamente, nos permiten observar la intersección de un elemento con la ventana gráfica o uno de los elementos de su padre. Para crear uno, podemos llamar a new IntersectionObserver()
, lo que crea una nueva instancia de observador. Siempre que creamos un nuevo observador, debemos pasarle una función de devolución de llamada que se llama cuando el observador ha observado una intersección de un elemento. Travis Almand tiene una explicación detallada del Intersection Observer que puede leer, pero lo que necesitamos por ahora es una función de devolución de llamada como primer parámetro y un objeto de opciones como segundo parámetro.
function createObserver() { const options = { rootMargin: "0px 0px -200px 0px", threshold: 1 } const callback = () = { console.log("observed something") } return new IntersectionObserver(callback, options)}
Se crea el observador, pero no se observa nada en este momento. Necesitaremos observar los elementos de encabezado en nuestro Markdown, así que recorrámoslos y agréguemoslos al observador con la observe()
función.
const observer = createObserver()$headings.map(heading = observer.observe(heading))
Como queremos actualizar nuestra lista de enlaces, la pasaremos a la observer
función como $links
parámetro, porque no queremos volver a leer el DOM en cada actualización por razones de rendimiento. En la handleObserver
función, averiguamos si un encabezado se cruza con la ventana gráfica, luego lo obtenemos id
y lo pasamos a una función llamada updateLinks
que maneja la actualización de la clase de los enlaces en nuestra tabla de contenido.
function handleObserver(entries, observer, $links) { entries.forEach((entry)= { const { target, isIntersecting, intersectionRatio } = entry if (isIntersecting intersectionRatio = 1) { const visibleId = `#${target.getAttribute('id')}` updateLinks(visibleId, $links) } })}
Escribamos la función para actualizar la lista de enlaces. Necesitamos recorrer todos los enlaces, eliminar la .is-active
clase si existe y agregarla solo al elemento que está realmente activo.
function updateLinks(visibleId, $links) { $links.map(link = { let href = link.getAttribute('href') link.classList.remove('is-active') if(href === visibleId) link.classList.add('is-active') })}
El final de nuestra init()
función crea un observador, observa todos los encabezados y actualiza la lista de enlaces para que el enlace activo se resalte cuando el observador note un cambio.
async function init() { // Parsing Markdown const $aside = document.querySelector('#aside');
// Generating a list of heading links const $headings = [...$main.querySelectorAll('h1, h2')];
// Adding an Intersection Observer const $links = [...$aside.querySelectorAll('a')] const observer = createObserver($links) $headings.map(heading = observer.observe(heading))}
Desplazarse a la sección de animación.
La siguiente parte es crear una animación de desplazamiento para que, cuando se haga clic en un enlace de la tabla de contenido, el usuario se desplaza hasta la posición del encabezado y salta allí abruptamente. A esto se le suele llamar desplazamiento suave.
Las animaciones de desplazamiento pueden ser perjudiciales si un usuario prefiere un movimiento reducido, por lo que solo debemos animar este comportamiento de desplazamiento si el usuario no ha especificado lo contrario. Con window.matchMedia('(prefers-reduced-motion)')
, podemos leer las preferencias del usuario y adaptar nuestra animación en consecuencia. Eso significa que necesitamos un detector de eventos de clic en cada enlace. Como necesitamos desplazarnos hasta los encabezados, también pasaremos nuestra lista de $headings
y el motionQuery
.
const motionQuery = window.matchMedia('(prefers-reduced-motion)');
$links.map(link = { link.addEventListener("click", (evt) = handleLinkClick(evt, $headings, motionQuery) )})
Escribamos nuestra handleLinkClick
función, que se llama cada vez que se hace clic en un enlace. Primero, debemos evitar el comportamiento predeterminado de los enlaces, que sería saltar directamente a la sección. Luego leeremos el href
atributo del enlace en el que se hizo clic y buscaremos el encabezado con el id
atributo correspondiente. Con un tabindex
valor de -1 y focus()
, podemos enfocar nuestro rumbo para que los usuarios sepan hacia dónde saltaron. Finalmente, agregamos la animación de desplazamiento llamando scroll()
a nuestra ventana.
Aquí es donde motionQuery
entra en juego nuestro. Si el usuario prefiere un movimiento reducido, el comportamiento será instant
; de lo contrario, así será smooth
. La top
opción agrega un poco de margen de desplazamiento en la parte superior de los títulos para evitar que se peguen a la parte superior de la ventana.
function handleLinkClick(evt, $headings, motionQuery) { evt.preventDefault() let id = evt.target.getAttribute("href").replace('#', '') let section = $headings.find(heading = heading.getAttribute('id') === id) section.setAttribute('tabindex', -1) section.focus()
window.scroll({ behavior: motionQuery.matches ? 'instant' : 'smooth', top: section.offsetTop - 20 })}
Animar la lista de enlaces.
Para la última parte, utilizaremos Vue transition-group
, que es muy útil para transiciones de listas. Aquí está la excelente introducción de Sarah Drasner a las transiciones de Vue si nunca antes ha trabajado con ellas. Son especialmente geniales porque nos brindan enlaces del ciclo de vida de la animación con fácil acceso a las animaciones CSS.
Vue nos adjunta automáticamente clases CSS cuando se agrega ( v-enter
) o elimina ( v-leave
) un elemento de una lista, y también con clases para cuando la animación está activa ( v-enter-active
y v-leave-active
). Esto es perfecto para nuestro caso porque podemos variar la animación cuando se agregan o eliminan subtítulos de nuestra lista. Para usarlos, necesitaremos envolver nuestros li
elementos en nuestra tabla de contenido con un transition-group
elemento. El atributo de nombre transition-group
define cómo se llamarán las animaciones CSS, el atributo de etiqueta debe ser nuestro ul
elemento principal.
transition-group name="list" tag="ul" li v-for="(item, index) in activeHeadings" v-bind:key="item.id" a :href="item.id" {{ item.text }} /a /li/transition-group
Ahora necesitamos agregar las transiciones CSS reales. Cada vez que un elemento entra o sale de él, debe animarse desde no visible ( opacity: 0
) y moverse un poco hacia abajo ( transform: translateY(10px)
).
.list-enter, .list-leave-to { opacity: 0; transform: translateY(10px);}
Luego definimos qué propiedad CSS queremos animar. Por razones de rendimiento, solo queremos animar las propiedades transform
y opacity
. CSS nos permite encadenar las transiciones con diferentes tiempos: transform
debería tomar 0,8 segundos y el desvanecimiento solo 0,4 segundos.
.list-leave-active, .list-move { transition: transform 0.8s, opacity 0.4s;}
Luego queremos agregar un poco de retraso cuando se agrega un nuevo elemento, de modo que los subtítulos se desvanezcan después de que el título principal se mueva hacia arriba o hacia abajo. Podemos hacer uso del v-enter-active
gancho para hacer eso:
.list-enter-active { transition: transform 0.8s ease 0.4s, opacity 0.4s ease 0.4s;}
Finalmente, podemos agregar posicionamiento absoluto a los elementos que se van para evitar saltos bruscos cuando los demás elementos se están animando:
.list-leave-active { position: absolute;}
Dado que la interacción de desplazamiento desvanece los elementos, es recomendable evitar la interacción de desplazamiento en caso de que alguien se desplace muy rápido. Al eliminar el rebote de la interacción, podemos evitar que las animaciones inacabadas se superpongan a otras animaciones. Puede escribir su propia función antirrebote o simplemente utilizar la función antirrebote de lodash. Para nuestro ejemplo, la forma más sencilla de evitar actualizaciones de animación sin terminar es involucrar la función de devolución de llamada de Intersection Observer con una función antirrebote y pasar la función antirrebote al observador.
const debouncedFunction = _.debounce(this.handleObserver)this.observer = new IntersectionObserver(debouncedFunction,options)
Aquí está la demostración final.
Nuevamente, una tabla de contenido es una gran adición a cualquier contenido extenso. Ayuda a aclarar qué contenido se cubre y proporciona acceso rápido a contenido específico. El uso de las animaciones de la lista de Intersection Observer y Vue encima puede ayudar a que una tabla de contenidos sea aún más interactiva e incluso permitir que sirva como una indicación del progreso de la lectura. Pero incluso si sólo agregas una lista de enlaces, ya será una gran característica para el usuario que lee tu contenido.
Deja un comentario