Análisis de Markdown en una tabla de contenido automatizada | Programar Plus

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 le muestra al usuario una descripción general útil de qué contenido 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 desde los encabezados. Después de eso, haremos uso de la API Intersection Observer para averiguar qué sección está actualmente activa, agregar una animación de desplazamiento cuando se hace clic en un enlace y, finalmente, aprender cómo funciona Vue. <transition-group> nos permite crear una buena lista animada según la sección que esté activa actualmente.

Análisis de rebajas

En la web, el contenido de texto a menudo se entrega en forma de Markdown. Si no lo ha usado, hay muchas razones por las que Markdown es una excelente opción para el contenido de texto. Vamos a usar 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 al marked(<markdown>, <options>) función para analizar el 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 buscar 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();

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 todo <h1> y <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 matriz de elementos que se ve así:

[
  {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 de encabezado, podemos usar literales de plantilla ES6 para generar los elementos HTML que necesitamos para la tabla de contenido.

Primero, recorremos todos los encabezados y creamos <li> elementos. Si estamos trabajando con un <h2> con depth: 2, agregaremos una clase de relleno adicional, .pl-4, para sangrarlas. 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 envuélvalos dentro de un <ul> elemento.

function generateLinkMarkup($contentElement) {
  // ...
  const htmlMarkup = parsedHeadings.map(h => `
  <li class="${h.depth > 1 ? 'pl-4' : ''}">
    <a href="https://css-tricks.com/parsing-markdown-into-an-automated-table-of-contents/#${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        
}

Adición de un observador de intersección

A continuación, debemos averiguar qué parte del contenido estamos leyendo actualmente. Los observadores de intersecciones son la opción perfecta para esto. MDN define Intersection Observer de la siguiente manera:

La API Intersection Observer proporciona una forma de observar de forma asíncrona los cambios en la intersección de un elemento de destino con un elemento antepasado o con la ventana gráfica de un documento de nivel superior.

Entonces, 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 un nuevo IntersectionObserver(), que crea una nueva instancia de observador. Cada vez 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. Tendremos que observar los elementos de encabezado en nuestro Markdown, así que vamos a recorrerlos y agregarlos al observador con el observe() función.

const observer = createObserver()
$headings.map(heading => observer.observe(heading))

Como queremos actualizar nuestra lista de enlaces, se la pasaremos al observer funcionar como un $links parámetro, porque no queremos volver a leer el DOM en cada actualización por motivos de rendimiento. En el handleObserver función, averiguamos si un encabezado se cruza con la ventana gráfica, luego obtenemos su id y pasarlo 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 el .is-active class si existe, y agréguelo 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 nuestro init() La 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))
}

Desplácese 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 en la tabla de contenido, el usuario se desplace a la posición del encabezado y salte allí abruptamente. Esto a menudo se denomina desplazamiento suave.

Las animaciones de desplazamiento pueden ser dañinas 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 la preferencia 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 a 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 nuestro 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 leemos el href atributo del enlace en el que se hizo clic y busque el encabezado con el correspondiente id atributo. Con un tabindex valor de -1 y focus(), podemos enfocar nuestro encabezado para que los usuarios sepan a dónde saltaron. Finalmente, agregamos la animación de desplazamiento llamando scroll() en nuestra ventana.

Aquí es donde nuestro motionQuery entra. Si el usuario prefiere el movimiento reducido, el comportamiento será instant; de lo contrario, será smooth. El top La opción agrega un poco de margen de desplazamiento en la parte superior de los encabezados para evitar que se adhieran 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
  })
}

Para la última parte, haremos uso de Vue <transition-group>, que es muy útil para las transiciones de lista. Aquí está la excelente introducción de Sarah Drasner a las transiciones Vue si nunca ha trabajado con ellas antes. Son especialmente buenos porque nos brindan ganchos de ciclo de vida de animación con fácil acceso a animaciones CSS.

Vue adjunta automáticamente clases de CSS para nosotros cuando se agrega un elemento (v-enter) o eliminado (v-leave) 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 nuestro <li> elementos en nuestra tabla de contenido con un <transition-group> elemento. El atributo de nombre del <transition-group> define cómo se llamarán las animaciones CSS, el atributo de la etiqueta debe ser nuestro padre <ul> elemento.

<transition-group name="list" tag="ul">
  <li v-for="(item, index) in activeHeadings" v-bind:key="https://css-tricks.com/parsing-markdown-into-an-automated-table-of-contents/item.id">
    <a :href="https://css-tricks.com/parsing-markdown-into-an-automated-table-of-contents/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 se movió 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 el transform y el opacity propiedades CSS nos permite encadenar las transiciones con diferentes tiempos: el transform debería tomar 0.8 segundos y el desvanecimiento solo 0.4s.

.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, para que los subtítulos se desvanezcan después de que el encabezado principal se mueva hacia arriba o hacia abajo. Podemos hacer uso de la 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 están saliendo para evitar saltos repentinos cuando los otros elementos están animando:

.list-leave-active {
  position: absolute;
}

Dado que la interacción de desplazamiento hace que los elementos se desvanezcan hacia adentro y hacia afuera, es recomendable eliminar el rebote de 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 sin terminar se superpongan con otras animaciones. Puede escribir su propia función antirrebote o simplemente usar la función antirrebote de lodash. Para nuestro ejemplo, la forma más sencilla de evitar actualizaciones de animación sin terminar es envolver la función de devolución de llamada 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 de formato largo. Ayuda a aclarar qué contenido está cubierto y brinda acceso rápido a contenido específico. El uso de Intersection Observer y las animaciones de lista de Vue encima puede ayudar a que una tabla de contenido sea aún más interactiva e incluso permitir que sirva como una indicación del progreso de la lectura. Pero incluso si solo agrega una lista de enlaces, ya será una gran característica para el usuario que lee su contenido.

(Visited 4 times, 1 visits today)