Discourse Lightbox - Migrando desde Magnific popup

Discourse permite a los usuarios subir imágenes en publicaciones (y en otros lugares). A veces, esas imágenes son demasiado grandes para mostrarse, por lo que Discourse crea una versión escalada de la imagen y la agrega a la publicación. Cuando haces clic en esa imagen escalada, aparece una superposición agradable que contiene la imagen en tamaño completo; esto se conoce comúnmente como «lightbox».

Actualmente, Discourse utiliza una biblioteca llamada Magnific Popup para manejar el comportamiento de los lightboxes. Este tema trata sobre la renovación de los lightboxes en Discourse mediante la migración desde Magnific Popup hacia una solución más alineada con las expectativas actuales de usuarios y desarrolladores.

El porqué

Magnific es una biblioteca excelente, y la cantidad de estrellas en la página del proyecto indica significativamente cuántos problemas ha resuelto para usuarios y desarrolladores a lo largo de los años.

También estaba a la vanguardia en términos de facilidad de uso y oferta de funciones. Esto se hace evidente al considerar que casi toda la funcionalidad que ves hoy ya estaba presente en la versión 1.0, lanzada en 2014.

Entonces, ¿qué nos deja esto? Lo mantendré breve. Magnific Popup fue creado para un mundo completamente diferente, donde la compatibilidad entre navegadores estaba muy por detrás de lo que es hoy. Esto significa cuatro cosas:

  1. Tiene jQuery como dependencia.
  2. Contiene código destinado a navegadores antiguos.
  3. El dispositivo promedio utilizado para acceder a la web ha cambiado mucho desde entonces.
  4. Fue diseñado priorizando algo diferente a las aplicaciones de una sola página (SPA) como Discourse, es decir, páginas estáticas. Esto genera problemas de rendimiento específicos para las SPA, los cuales abordaremos más adelante.

Dado el estado actual de los estándares web y la capacidad de realizar todo tipo de magia con JavaScript nativo (vanilla), es comprensible que proyectos grandes como Discourse estén alejándose de tener jQuery como dependencia. Ten en cuenta que las discusiones sobre jQuery están fuera de tema aquí; esto es principalmente para dar contexto.

Así que, ese es el porqué: menos jQuery, menos código para navegadores antiguos, mejor rendimiento general y mejor soporte para el dispositivo promedio, que ahora es muy diferente.

El cómo

No hay escasez de soluciones disponibles, y hay un debate sobre no reinventar la rueda, pero… no vamos a entrar en eso y lo mantendremos breve.

Quizás la forma más corta de abordar este punto difícil sea decir… no importa qué tan bien se ajuste una solución de catálogo… no se ajustará tan bien como algo hecho a medida, y dejémoslo ahí.

Hay muchas cosas a considerar aquí, ya sea el exceso de funciones en bibliotecas que cubren numerosos casos de uso como imágenes, iframes, videos y similares, o la falta de claridad en torno a las licencias.

Conocer los casos de uso específicos que Discourse necesita soportar permite evitar código innecesario, lo que posibilita agregar funciones adicionales dirigidas sin terminar con una gran sobrecarga.

Por lo tanto, ahora tenemos una línea base bastante sólida. Los requisitos son:

  1. Sin jQuery.
  2. Enfocarse solo en imágenes por ahora.
  3. Soporte para mejoras en la experiencia de usuario, como deslizar en móviles.
  4. Integrarlo con Discourse para permitir futuras personalizaciones/mejoras utilizando los sistemas actuales de temas y complementos.

El qué

Discourse es un entorno Ember, por lo que el nuevo lightbox debería ser un componente de Ember para manejar el marcado y los datos. Además, dado que existe la expectativa de que se puedan configurar lightboxes en cualquier lugar de la aplicación, tener un servicio de lightbox tiene mucho sentido. Esto permitirá a los desarrolladores:

  1. Inyectar el servicio en cualquier componente y vincular los métodos de configuración/limpieza del servicio al ciclo de vida del componente.
  2. Buscar el servicio en cualquier lugar de la aplicación y llamar a sus métodos de configuración/limpieza.

Adicionalmente, como mejora en la experiencia de usuario, podemos agregar un poco de abstracción y crear algunas funciones de utilidad que puedan usarse en temas. Más sobre esto más adelante.

Dado que uno de los objetivos es permitir que los temas y complementos extiendan la funcionalidad, el servicio de lightbox comunicará los cambios de estado a través de appEvents. Emitirá los siguientes eventos:

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

Cada evento se activará con el tipo de datos que los desarrolladores esperarían típicamente. Por ejemplo, el evento lightbox:opened contendrá una lista de todos los elementos y el elemento actual en el que se abrió el lightbox. Más sobre esto más adelante.

Con esto aclarado, sigamos adelante.

En las últimas semanas, he estado trabajando en una propuesta de pull request (PR) que introduce Discourse Lightbox.

A primera vista, podría parecer un poco grande, así que desglosemos.

Probar algo que depende de mucha interacción del usuario es complicado, por lo que el conjunto de pruebas del lightbox incluye 63 pruebas con 291 aserciones.

Dejando de lado el tamaño de las pruebas, hagamos una comparación rápida de tamaño con Magnific Popup (ambas sin minificar)

Discourse Lightbox LOC Magnific Popup LOC Diferencia
JavaScript 1197 1860 (35%)
CSS 813 351 131%
Plantillas 401 0 -
Total 2411 2211 9.1%

Por lo tanto, Discourse Lightbox tiene aproximadamente un 9 % más de código que Magnific. Por supuesto, eso es solo parte de la historia por dos razones:

  1. No hemos tenido en cuenta jQuery, del cual depende Magnific. jQuery 3.6 tiene aproximadamente 11k LOC.
  2. Discourse Lightbox agrega más funciones en comparación con Magnific.

Entremos en esas funciones.

Cualquier tartamudeo que veas en los videos a continuación está relacionado con la grabación, no con lo que experimentarán los usuarios. Todas las animaciones/transiciones se ejecutarán a un sólido 60 FPS.

Así que, sin más demora,

Diseño básico

Para comparar, aquí está lo mismo en la implementación actual de Magnific

Algunos puntos sobre los videos anteriores:

  1. El nuevo lightbox utilizará la imagen como fondo en lugar del fondo oscuro semitransparente genérico. La imagen, por definición, se complementará a sí misma. Ten en cuenta que esto no agrega sobrecarga de red. La imagen utilizada en el fondo es la del cuerpo de la publicación, lo que significa que ya estará en caché cuando los usuarios abran el lightbox.

  2. Discourse Lightbox opta por una interfaz de usuario fija. En lugar de tener el botón de cerrar, el título y los metadatos de la imagen adjuntos a la imagen, tienen sus propios espacios reservados. Esto ayudará a reducir los saltos al navegar entre imágenes de diferentes dimensiones.

  3. La navegación entre imágenes puede realizarse con las flechas del lightbox o mediante atajos de teclado. o para el siguiente y o para el anterior. En localizaciones RTL, los atajos se invierten en consecuencia.

El diseño en móviles es muy similar, excepto que las flechas no se muestran porque se añaden gestos de deslizar para la navegación.

Desliza a la izquierda en móvil para el siguiente y a la derecha para el anterior. En localizaciones RTL, los gestos de deslizar se invierten.

Zoom

El nuevo lightbox tiene un botón de zoom dedicado que es visible cuando la imagen que estás viendo es más grande que las dimensiones de la ventana de visualización. Hacer clic en el botón de zoom acercará la imagen, y hacer clic nuevamente la alejará. Además, ahora hay un atajo de teclado para hacer zoom in/out: Z. Por último, si la imagen se puede hacer zoom, hacer clic en ella tendrá el mismo efecto.

Aquí hay un video que demuestra los tres métodos

Ten en cuenta que, aunque no se demuestra en el video anterior, el cursor, al pasar el ratón sobre una imagen que se puede hacer zoom, cambiará para reflejarlo. También cambiará a un icono de zoom-out cuando pases el ratón sobre una imagen que ya está ampliada.

El zoom funciona de manera diferente en el nuevo lightbox. En escritorio, la sección ampliada de la imagen seguirá al cursor.

No hay hover en móviles; utiliza el desplazamiento táctil normal para moverse por una imagen ampliada.

Rotación

El nuevo lightbox agrega un botón de rotación dedicado para rotar la imagen en incrementos de 90 grados. La rotación también tiene un atajo de teclado: la tecla R. Así es como se ve

La rotación y el zoom se pueden combinar.

Modo pantalla completa

El nuevo lightbox agrega un botón de pantalla completa. El botón hará que la ventana del navegador entre en modo pantalla completa. El atajo de teclado es M

Mantendrá un registro de su estado y volverá al modo regular cuando se desactive la pantalla completa o cuando se cierre el lightbox.

Descargar

El lightbox actual agrega un enlace de «descargar» debajo de la imagen. El nuevo lightbox hace lo mismo, pero cambia eso a un icono y lo agrega al pie de página del lightbox. Sigue respetando los mismos permisos. Si…

prevent_anons_from_downloading_images

está habilitado y el usuario no ha iniciado sesión, el icono de descarga no se mostrará.

Nueva pestaña

El lightbox actual agrega un enlace de «original» debajo de la imagen que la abre en una nueva pestaña. El nuevo lightbox hace lo mismo, pero lo agrega como un icono en el encabezado del lightbox. También respetará el mismo permiso establecido para el icono de descarga.

Título de la imagen

El nuevo lightbox se centra principalmente en la imagen. Los títulos de las imágenes se truncarán a una línea por defecto, pero admiten la expansión. Aquí hay un ejemplo de cómo se ve

Hay un atajo de teclado para expandir/contraer el título: T, y el título no se mostrará cuando la imagen esté ampliada o rotada.

Carrusel

Una función recientemente disponible es mostrar todas las imágenes de la galería en un carrusel. El diseño dependerá de la pantalla del dispositivo; puede ser horizontal o vertical, y así es como se ve.

Hay un atajo de teclado para activar/desactivar el carrusel: A

y así es como se ve en móviles

Deslizar hacia abajo en móviles activará/desactivará el carrusel.

Cierre

La tecla Esc sigue funcionando como antes en escritorio para cerrar el lightbox. Ahora hay un gesto de deslizar adicional en móviles: puedes deslizar hacia arriba para cerrar el lightbox.

Accesibilidad

Más allá de lo básico como las etiquetas de los botones, el nuevo lightbox agrega un elemento anunciador para lectores de pantalla fuera de la pantalla. Cuando navegas a una imagen dentro del lightbox, leerá su índice y título basándose en el siguiente formato.

imagen %{current} de %{total}: %{title}

Aquí hay un ejemplo breve de eso

El nuevo lightbox también elimina todos los botones innecesarios que no sirven para nada en los lectores de pantalla mediante aria-hidden.

Recuerda que la accesibilidad es una misión continua y esto está lejos de estar completo. Siempre hay algo que mejorar, pero me detuve aquí para mantener las cosas simples para la v1.

Esto cubre todas las funciones del nuevo lightbox.

Pasemos a un poco de lenguaje para desarrolladores.

Escuchadores de eventos

El lightbox actual agrega escuchadores de eventos de clic a cada imagen individual de lightbox en publicaciones cocinadas. Eso significa que una publicación con 20 imágenes tendrá 20 escuchadores de eventos de clic para los lightboxes.

El nuevo lightbox aprovecha la delegación de eventos y solo agrega un escuchador de eventos a la publicación en sí.

Aquí hay un recuento de escuchadores de eventos del lightbox actual en una ventana de incógnito de un usuario anónimo en una publicación con 20 imágenes después de forzar la recolección de basura

y aquí está la misma publicación con el nuevo lightbox

Además, navegar dentro del lightbox actualmente agrega escuchadores de eventos que terminan huérfanos e interfieren con la recolección de basura. Aquí hay un gráfico de:

  1. Cargar una página de tema con una publicación que contiene 20 imágenes.
  2. Abrir el lightbox y navegar por las 20 imágenes tres veces consecutivas.
  3. Cerrar el lightbox.
  4. Forzar la recolección de basura del navegador.

Lightbox actual:

Nuevo lightbox:

En cuanto a los escuchadores de eventos en los propios lightboxes, estos no parecen limpiarse actualmente en Magnific (recuerda, no fue construido para aplicaciones de una sola página).

Una nota rápida sobre las pruebas anteriores: son muy rudimentarias y los números no pretenden ser «científicos»; el objetivo aquí es determinar la dirección, no los números exactos.

Notas para desarrolladores

Hablemos de configurar y limpiar lightboxes con el nuevo Discourse Lightbox.

Configuración y limpieza de lightboxes

Los desarrolladores tienen dos opciones.

  1. Inyectar el servicio de lightbox en un componente mediante:

    import { inject as service } from "@ember/service";
    
    //...
    
    @service lightbox
    

    Luego puedes llamar a:

    this.lightbox.setupLightboxes({
      container: yourContainer // Nodo DOM
      selector: ".css-selector" // Selector de cadena para los elementos que quieres lightbox
    })
    

    Luego, cada vez que quieras limpiar, simplemente llama a:

    this.lightbox.cleanupLightboxes()

    Eso es todo.

  2. Si no quieres inyectar el servicio de lightbox, puedes importar setupLightboxes y cleanupLightboxes así:

    import {
      cleanupLightboxes,
      setupLightboxes,
    } from "discourse/lib/lightbox";
    

    El resto es el mismo que inyectar el servicio. Esas dos funciones buscarán el servicio por ti. Así que:

    setupLightboxes({
     container: yourContainer // Nodo DOM
     selector: ".css-selector" // Selector de cadena para los elementos que quieres lightbox
    })
    
    //....
    
    cleanupLightboxes()
    

Ten en cuenta que tanto llamar directamente desde el servicio como a través de las funciones de utilidad también aceptarán una nodeList para compatibilidad con versiones anteriores, pero no se recomienda.

Una última nota al respecto es que también puedes tener un elemento que no sea una imagen actuando como desencadenante para abrir un lightbox que hayas configurado. Por ejemplo:

<div class="my-container">
  <img class="my-selector" src="foo">
  <img class="my-selector" src="bar">
  ....
  <button>Abrir Lightbox</button>
</div>

Haría algo así para configurar lightboxes básicos en el div anterior.

import {
  cleanupLightboxes,
  setupLightboxes,
} from "discourse/lib/lightbox";

//...

setupLightboxes({
   container: document.querySelector(".my-container"),
   selector: ".my-selector"
})

Para que el botón abra el lightbox, todo lo que necesitas hacer es agregar data-lightbox-trigger así:

<button data-lightbox-trigger>Abrir Lightbox</button>

El resto se maneja automáticamente.

Finalmente, cada vez que quieras limpiar, llama a:

cleanupLightboxes()

La limpieza no es realmente crítica, ya que el servicio de lightbox se limpiará automáticamente cada vez que se active el evento dom:clean en la aplicación (en las transiciones de ruta).

Escuchando eventos de lightbox

El nuevo lightbox disparará eventos, como discutimos anteriormente. Esos eventos son:

  • lightbox:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

lightbox:opened

Este evento se disparará cuando se abra el lightbox y contendrá dos objetos.

  1. items: este es un array de todas las imágenes en el lightbox actual. Cada una será un objeto.
  2. currentItem: este es el objeto del elemento actual en el que se abrió el lightbox.

Un objeto de elemento se ve así:

{
  "fullsizeURL": "https://global.discourse-cdn.com/meta/original/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff.jpeg",
  "smallURL": "https://global.discourse-cdn.com/meta/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg",
  "downloadURL": "/uploads/short-url/56rKTvkvmL6c2C8OFQtMo3D8sIn.jpeg?dl=1",
  "title": "Close-up Photo of a Black Microphone on Stand",
  "fileDetails": "1200×1500 88.9 KB",
  "dominantColor": "793C6D",
  "aspectRatio": "400 / 500",
  "index": 0,
  "cssVars": "--dominant-color: #793C6D;--aspect-ratio: 400 / 500;--small-url: url(https://global.discourse-cdn.com/meta/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg);",
  "isLoaded": true,
  "hasLoadingError": false,
  "width": 1200,
  "height": 1500,
  "canZoom": true
}

lightbox:item-will-change

Este evento se dispara justo antes de que cambie el elemento actual en el lightbox. Tendrá el currentItem (el que está a punto de cambiar).

lightbox:item-did-change

Este evento se dispara justo después de que el elemento en el lightbox cambia y termina de cargar, y tendrá currentItem como argumento.

lightbox:closed

Este evento se dispara justo después de que se cierra el lightbox y no tiene argumentos.

Con los eventos anteriores, un componente de tema teórico puede agregar fácilmente analítica al lightbox así:

api.onAppEvent('lightbox:opened', ({items, currentItem}) => {
  console.log({items});
  console.log({currentItem});

  // tu código de analítica aquí
});

o otras ideas similares.

El CSS

El nuevo lightbox utiliza la convención de nombrado BEM para las clases HTML. Aquí hay una lista completa de los selectores que puedes usar:

html.has-lightbox {
  // css para el elemento HTML cuando los lightboxes están abiertos
}

.d-lightbox {
  &--is-visible {
    // Elemento principal del lightbox
  }

  &__content {
    // Contenedor de contenido interno del lightbox
  }
}

.d-lightbox {
  &__content__header {
    // Encabezado del lightbox
  }
}

.d-lightbox {
  &__content__body {
    // Cuerpo del lightbox (contiene la imagen principal)

    &__backdrop {
      // Fondo del lightbox
    }

    &__main-image {
      // Imagen principal del lightbox
    }

    &__error-message {
      // Mensaje de error del lightbox
    }

    &__previous-button,
    &__next-button {
      // Botones anterior/siguiente principales del lightbox
    }
  }
}

.d-lightbox {
  &__content__footer {
    // Pie de página del lightbox

    &__main-title {
      // Título de la imagen del lightbox
     
      &__item-file-details {
        // Detalles del archivo del lightbox, ej. "1000x582 183KB"
      }
    }
  }
}

.d-lightbox {
  &__content__carousel {
    // Contenedor del carrusel del lightbox

    &__previous-button,
    &__next-button {
      // Botones anterior/siguiente del carrusel del lightbox
    }
  }
}

.d-lightbox {
  &__content__carousel {
    &__carousel-items {
      // Contenedor de elementos del carrusel del lightbox

      &__item,
      &__item--is-current {
        // Elemento del carrusel del lightbox
      }

      &__item--is-current {
        // Elemento actual del carrusel del lightbox
      }
    }
  }
}

.d-lightbox {
  &--is-vertical &__content__carousel {
    // Estilos verticales del carrusel del lightbox
  }
}

.d-lightbox {
  &--is-horizontal &__content__carousel {
    // Estilos horizontales del carrusel del lightbox
  }
}

.d-lightbox {
  .btn-flat {
    // Estilos para todos los botones del lightbox
  }
}

.d-lightbox {
  &__content {
    &__focus-trap,
    &__screen-reader-announcer {
      // Trampa de enfoque y anunciador para lectores de pantalla del lightbox. Estos están fuera de pantalla
    }
  }
}

/* Estilos de estado */

// Carrusel
.d-lightbox {
  &--has-carousel {
    // Estilos del lightbox cuando el carrusel está abierto
  }
}

// Título expandido
.d-lightbox {
  &--has-expanded-title {
    // Estilos del lightbox cuando el título está expandido
  }
}

// Zoom
.d-lightbox {
  &--can-zoom {
    // Estilos del lightbox cuando la imagen se puede hacer zoom
  }

  &--is-zoomed {
    // Estilos del lightbox cuando la imagen está ampliada
  }
}

// Rotación
.d-lightbox {
  &--is-rotated {
    // Estilos del lightbox cuando la imagen está rotada
  }
}

// Pantalla completa
.d-lightbox {
  &--is-fullscreen {
    // Estilos del lightbox cuando la imagen está en pantalla completa
  }
}

Ahora que hemos cubierto el qué, finalmente podemos pasar a…

El cuándo

Actualmente, la PR está lista para revisión, lo cual debe ocurrir primero. Después de pasar la revisión, estará disponible para los sitios que se actualicen. La PR agrega una nueva configuración de sitio temporal mientras transitamos desde Magnific Popup hacia Discourse Lightbox. El nombre de la configuración es:

enable_experimental_lightbox

Si la configuración está deshabilitada, la PR no tendrá ningún efecto y todo continuará funcionando como antes con Magnific Popup.

Discourse Lightbox reemplazará a Magnific Popup en publicaciones cocinadas, mensajes de chat y componentes de cargador de imágenes cuando la configuración se active.

Hoja de ruta

  1. Revisión de PR
  2. Fusión de PR
  3. Ventana de retroalimentación general (1-2 semanas)
  4. PR para eliminar Magnific Popup del núcleo y eliminar la configuración de sitio experimental.
  5. TBD: objetivos adicionales, como un componente de tema que explore diferentes diseños (debería ser sencillo ya que el nuevo lightbox usa CSS Grid para el diseño).

Agradecimientos

  • Este trabajo fue generosamente patrocinado por CDCK :pray:
  • Un gran amor :heart: va para Dmytro Semenov, el creador de Magnific Popup, por crear algo que estaba muy por delante de su tiempo.
  • Las imágenes utilizadas en las demostraciones anteriores son cortesía de Irina Iriser @pexels
38 Me gusta

Estoy acostumbrado, de YouTube y otras aplicaciones de escritorio, a que F ponga la pantalla completa.
¿Hay alguna razón para no usar la memoria muscular para el atajo?

4 Me gusta

Este se ve particularmente bien. :raised_hands:

Una cosa que me hubiera gustado es poder ir a las imágenes de la siguiente publicación mientras se usan las flechas. De esa manera, si estoy en un tema como “Gatos lindos”, puedo usar las flechas para navegar sin problemas a las imágenes de la siguiente publicación. Habría varias cosas a tener en cuenta, pero sería muy bueno. Actualmente, me veo obligado a cerrar la ventana emergente, ir a la siguiente publicación, hacer clic en la primera imagen, …

16 Me gusta

bootstrap 3 también requiere jqwery. Discourse usa bootstrap 3. Por lo tanto, jqwery se instalará como una dependencia en nuestro foro. ¿Es eso cierto?

2 Me gusta

EmberJS también solía requerir jQuery y Discourse es una aplicación EmberJS. Hemos estado trabajando, durante un par de años, en eliminar el uso de jQuery de Discourse en sí, plugins y componentes oficiales.

Es mucho trabajo, pero nos estamos acercando. Este mismo tema es uno de los muchos esfuerzos para lograrlo.

11 Me gusta

Esto ya se ha implementado. :partying_face:

Recopilaremos comentarios en ese tema durante un corto período, así que échale un vistazo y dinos lo que piensas. :slight_smile: :+1:

6 Me gusta