Discourse позволяет пользователям загружать изображения в сообщениях (и в других местах). Иногда эти изображения слишком велики для отображения, поэтому Discourse создает их уменьшенную версию и добавляет её в сообщение. При клике на такое уменьшенное изображение появляется красивое всплывающее окно с полным изображением — это явление обычно называют «лайтбоксом» (lightboxing).
В настоящее время Discourse использует библиотеку Magnific Popup для реализации поведения лайтбокса. Эта тема посвящена обновлению лайтбоксов в Discourse путем миграции с Magnific Popup на решение, более соответствующее современным ожиданиям пользователей и разработчиков.
Зачем это нужно
Magnific — отличная библиотека, и количество звёзд на странице проекта явно указывает на то, сколько проблем она решила для пользователей и разработчиков за эти годы.
Кроме того, она опередила своё время с точки зрения простоты использования и функциональных возможностей. Это становится очевидным, если учесть, что практически весь функционал, который вы видите сегодня, уже присутствовал в версии 1.0, выпущенной в 2014 году.
Так где же мы сейчас? Я постараюсь быть кратким. Magnific Popup создавалась для совершенно другого мира, где кроссбраузерная совместимость была на гораздо более низком уровне, чем сегодня. Это означает четыре вещи:
- Она зависит от jQuery.
- Она содержит код, предназначенный для древних браузеров.
- Среднее устройство, используемое для доступа в интернет, с тех пор сильно изменилось.
- Она была разработана с приоритетом на что-то другое, кроме одностраничных приложений (SPA), таких как Discourse, а именно — на статические страницы. Это создает проблемы с производительностью, специфичные для SPA, о которых мы поговорим позже.
Учитывая текущее состояние веб-стандартов и то, какие чудеса можно творить с помощью чистого JavaScript, понятно, что крупные проекты, такие как Discourse, отказываются от зависимости от jQuery. Обратите внимание, что обсуждения jQuery здесь не по теме; это просто контекст.
Итак, вот зачем это нужно… меньше jQuery, меньше кода для древних браузеров, лучшая общая производительность и лучшая поддержка среднего устройства, которое сегодня сильно отличается от предыдущего.
Как это сделать
Предложений решений не счесть, и можно было бы поговорить о том, чтобы не изобретать велосипед, но… давайте не будем углубляться в это и оставим всё кратко.
Возможно, самый короткий способ решить эту проблему — сказать, что… как бы хорошо ни подходило готовое решение, оно никогда не будет сидеть так же идеально, как что-то сделанное на заказ. И на этом остановимся.
Здесь есть много нюансов, которые нужно учесть: будь то избыток функций в библиотеках, покрывающих множество случаев использования (изображения, iframe, видео и т. д.), или неясность вокруг лицензирования.
Зная конкретные случаи использования, которые должен поддерживать Discourse, можно избежать ненужного кода, что позволит добавлять целевые функции без лишней нагрузки.
Итак, у нас теперь есть хорошая базовая линия. Требования следующие:
- Никакого jQuery.
- Пока сосредоточиться только на изображениях.
- Поддержка улучшений качества жизни, таких как свайпы на мобильных устройствах.
- Интеграция с Discourse для возможности дальнейшей кастомизации и улучшения через текущие системы тем и плагинов.
Что это будет
Discourse — это проект на Ember, поэтому новый лайтбокс должен быть компонентом Ember для обработки разметки и данных. Кроме того, поскольку ожидается, что лайтбоксы можно будет настраивать в любом месте приложения, наличие сервиса лайтбокса имеет большой смысл. Это позволит разработчикам:
- Внедрить сервис в любой компонент и связать методы настройки/очистки сервиса с жизненным циклом компонента.
- Найти сервис в любом месте приложения и вызвать его методы настройки/очистки.
Кроме того, в качестве улучшения качества жизни мы можем добавить немного абстракции и создать несколько утилитарных функций, которые можно использовать в темах. Об этом позже.
Поскольку одна из целей — позволить темам и плагинам расширять функциональность, сервис лайтбокса будет сообщать об изменениях состояния через appEvents. Он будет генерировать следующие события:
lightbox:openedlightbox:item-will-changelightbox:item-did-changelightbox:closed
Каждое событие будет генерироваться с типом данных, который разработчики обычно ожидают. Например, событие lightbox:opened будет содержать список всех элементов и текущего элемента, на котором открылся лайтбокс. Об этом позже.
С этим разобрались, давайте двигаться дальше.
За последние несколько недель я работал над черновиком PR, который вводит Discourse Lightbox.
На первый взгляд он может показаться немного большим, поэтому давайте разберем его.
Тестирование чего-то, что зависит от большого количества взаимодействия с пользователем, — дело щекотливое, поэтому набор тестов для лайтбокса включает 63 теста с 291 утверждением.
Разобравшись с размером тестов, давайте быстро сравним размеры с Magnific Popup (оба в неминифицированном виде):
| Discourse Lightbox LOC | Magnific Popup LOC | Разница | |
|---|---|---|---|
| Javascript | 1197 | 1860 | (35%) |
| CSS | 813 | 351 | 131% |
| Templates | 401 | 0 | - |
| Total | 2411 | 2211 | 9.1% |
Итак, Discourse Lightbox имеет примерно на 9% больше кода, чем Magnific. Конечно, это только часть истории по двум причинам:
- Мы не учли jQuery, от которого зависит Magnific. jQuery 3.6 — это примерно 11 тыс. строк кода.
- Discourse Lightbox добавляет больше функций по сравнению с Magnific.
Давайте разберем эти функции.
Любые артефакты, которые вы видите в видео ниже, связаны с записью, а не с тем, что увидят пользователи. Все анимации и переходы будут работать стабильно на 60 FPS.
Итак, без дальнейших задержек,
Базовая разметка
Для сравнения, вот то же самое в текущей реализации Magnific:
Несколько замечаний по поводу видео выше:
-
Новый лайтбокс будет использовать изображение в качестве фона вместо универсального полупрозрачного темного фона. Изображение, по определению, будет гармонировать само с собой. Обратите внимание, что это не добавляет никакой сетевой нагрузки. Изображение, используемое в качестве фона, берется из тела сообщения, что означает, что оно уже будет закэшировано к моменту открытия лайтбокса пользователем.
-
Discourse Lightbox выбирает фиксированный интерфейс. Вместо того чтобы кнопка закрытия, заголовок и метаданные изображения были прикреплены к изображению, у них есть свои выделенные места. Это поможет уменьшить скачки при навигации между изображениями разных размеров.
-
Навигация между изображениями может осуществляться с помощью стрелок в лайтбоксе или с помощью горячих клавиш. → или ↓ — следующий, ← или ↑ — предыдущий. В локалях с правосторонним письмом (RTL) сочетания клавиш меняются местами.
Разметка на мобильных устройствах очень похожа, за исключением того, что стрелки не отображаются, так как добавлены жесты свайпа для навигации.
Свайп влево на мобильном — следующий, свайп вправо — предыдущий. В локалях с правосторонним письмом жесты свайпа меняются местами.
Масштабирование
Новый лайтбокс имеет отдельную кнопку масштабирования, которая отображается, когда просматриваемое изображение больше размеров области просмотра. Нажатие на кнопку масштабирования приближает изображение, повторное нажатие отдаляет его. Кроме того, теперь есть горячая клавиша для приближения/отдаления Z. Наконец, если изображение можно масштабировать, клик по нему даст тот же эффект.
Вот видео, демонстрирующее все три метода:
Обратите внимание, что хотя в видео выше это не показано, курсор при наведении на изображение, которое можно масштабировать, изменится, чтобы отразить это. Он также изменится на значок zoom-out, когда вы наведете курсор на уже увеличенное изображение.
Масштабирование работает по-другому в новом лайтбоксе. На рабочем столе увеличенная часть изображения будет следовать за курсором.
На мобильных устройствах нет наведения курсора; используется обычное касание-прокрутка для перемещения по увеличенному изображению.
Вращение
Новый лайтбокс добавляет отдельную кнопку вращения для поворота изображения на 90 градусов. Вращение также имеет горячую клавишу — клавишу R. Вот как это выглядит:
Вращение и масштабирование можно комбинировать.
Режим полного экрана
Новый лайтбокс добавляет кнопку полного экрана. Эта кнопка переведет окно браузера в режим полного экрана. Горячая клавиша — M.
Он будет отслеживать свое состояние и вернется в обычный режим, когда полный экран будет отключен или когда лайтбокс будет закрыт.
Скачивание
Текущий лайтбокс добавляет ссылку «скачать» под изображением. Новый лайтбокс делает то же самое, но меняет её на значок и добавляет в нижний колонтитул лайтбокса. Он по-прежнему соблюдает те же разрешения. Если…
prevent_anons_from_downloading_images
включено, и пользователь не вошел в систему, значок скачивания отображаться не будет.
Новая вкладка
Текущий лайтбокс добавляет ссылку «оригинал» под изображением, которая открывает его в новой вкладке. Новый лайтбокс делает то же самое, но добавляет значок в заголовок лайтбокса. Он также будет соблюдать те же разрешения, что и значок скачивания.
Заголовок изображения
Новый лайтбокс фокусируется в основном на изображении. Заголовки изображений по умолчанию обрезаются до одной строки, но поддерживают расширение. Вот пример того, как это выглядит:
Существует горячая клавиша для развертывания/сворачивания заголовка T, и заголовок не будет отображаться, когда изображение увеличено или повернуто.
Карусель
Новая доступная функция — отображение всех изображений в галерее в виде карусели. Разметка будет зависеть от размера экрана устройства; она может быть горизонтальной или вертикальной. Вот как это выглядит:
Существует горячая клавиша для переключения карусели — A.
А вот как это выглядит на мобильном:
Свайп вниз на мобильном устройстве включает/выключает карусель.
Закрытие
Клавиша Esc по-прежнему работает как раньше на рабочем столе для закрытия лайтбокса. Теперь на мобильных устройствах есть дополнительный жест свайпа: можно свайпнуть вверх, чтобы закрыть лайтбокс.
Доступность
Помимо основ, таких как подписи к кнопкам, новый лайтбокс добавляет элемент анонса для экранного чтения за пределами экрана. При навигации к изображению внутри лайтбокса он будет читать его индекс и заголовок в следующем формате:
image %{current} of %{total}: %{title}
Вот короткий пример этого:
Новый лайтбокс также удаляет все ненужные кнопки, которые не служат никакой цели для экранного чтения, используя aria-hidden.
Помните, что доступность — это непрерывная миссия, и это далеко не полный список. Всегда есть что улучшить, но я остановился здесь, чтобы сохранить простоту для версии 1.
Это охватывает все функции нового лайтбокса.
Перейдем к немного техническому языку.
Слушатели событий
Текущий лайтбокс добавляет обработчики событий клика к каждому отдельному изображению лайтбокса в обработанных сообщениях. Это означает, что сообщение с 20 изображениями будет иметь 20 обработчиков событий клика для лайтбоксов.
Новый лайтбокс использует делегирование событий и добавляет только один обработчик событий к самому сообщению.
Вот счетчик обработчиков событий текущего лайтбокса для анонимного пользователя в режиме инкогнито на сообщении с 20 изображениями после принудительной сборки мусора:
А вот то же сообщение с новым лайтбоксом:
Кроме того, навигация внутри лайтбокса в настоящее время добавляет обработчики событий, которые в итоге становятся сиротами и мешают сборке мусора. Вот диаграмма:
- Загрузите страницу темы с одним сообщением, содержащим 20 изображений.
- Откройте лайтбокс и пройдите через все 20 изображений три раза подряд.
- Закройте лайтбокс.
- Принудительно выполните сборку мусора в браузере.
Текущий лайтбокс:
Новый лайтбокс:
Что касается обработчиков событий самих лайтбоксов, то они, похоже, не очищаются в Magnific в настоящее время (помните, что он не был создан для одностраничных приложений).
Небольшое замечание о приведенных выше тестах. Они очень примитивны, и цифры не должны быть «научными»; цель здесь — определить направление, а не точные числа.
Заметки для разработчиков
Давайте поговорим о настройке и очистке лайтбоксов с помощью нового Discourse Lightbox.
Настройка и очистка лайтбоксов
У разработчиков есть два варианта.
-
Внедрение сервиса лайтбокса в компонент через:
import { inject as service } from "@ember/service"; //... @service lightboxЗатем вы можете вызвать:
this.lightbox.setupLightboxes({ container: yourContainer // DOM node selector: ".css-selector" // string selector for the elements you want to lightbox })Затем, когда захотите очистить, просто вызовите:
this.lightbox.cleanupLightboxes()Всё.
-
Если вы не хотите внедрять сервис лайтбокса, вы можете импортировать
setupLightboxesиcleanupLightboxesследующим образом:import { cleanupLightboxes, setupLightboxes, } from "discourse/lib/lightbox";Остальное такое же, как при внедрении сервиса. Эти две функции найдут сервис за вас. Так что:
setupLightboxes({ container: yourContainer // DOM node selector: ".css-selector" // string selector for the elements you want to lightbox }) //.... cleanupLightboxes()
Обратите внимание, что вызов как напрямую из сервиса, так и через утилитарные функции также принимает nodeList для обратной совместимости, но это не рекомендуется.
Еще одно замечание: вы также можете использовать не-изображение в качестве триггера для открытия настроенного лайтбокса. Например:
<div class="my-container">
<img class="my-selector" src="foo">
<img class="my-selector" src="bar">
....
<button>Open Lightbox</button>
</div>
Я бы сделал что-то подобное для настройки базовых лайтбоксов в div выше:
import {
cleanupLightboxes,
setupLightboxes,
} from "discourse/lib/lightbox";
//...
setupLightboxes({
container: document.querySelector(".my-container"),
selector: ".my-selector"
})
Чтобы кнопка открывала лайтбокс, всё, что вам нужно сделать, это добавить data-lightbox-trigger следующим образом:
<button data-lightbox-trigger>Open Lightbox</button>
Остальное обрабатывается автоматически.
Наконец, когда захотите очистить, вызовите:
cleanupLightboxes()
Очистка не является критически важной, поскольку сервис лайтбокса автоматически очистит всё, когда в приложении сработает событие dom:clean (при переходах между маршрутами).
Прослушивание событий лайтбокса
Новый лайтбокс будет генерировать события, как мы обсуждали ранее. Это события:
lightbox:openedlightbox:item-will-changelightbox:item-did-changelightbox:closed
lightbox:opened
Это событие генерируется при открытии лайтбокса и содержит два объекта.
items: это массив всех изображений в текущем лайтбоксе. Каждое из них будет объектом.currentItem: это объект текущего элемента, на котором был открыт лайтбокс.
Объект элемента выглядит так:
{
"fullsizeURL": "https://d11a6trkgmumsb.cloudfront.net/original/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff.jpeg",
"smallURL": "https://d11a6trkgmumsb.cloudfront.net/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://d11a6trkgmumsb.cloudfront.net/optimized/4X/2/3/c/23c5746c48803deac5c105081dba20555187c3ff_2_600x750.jpeg);",
"isLoaded": true,
"hasLoadingError": false,
"width": 1200,
"height": 1500,
"canZoom": true
}
lightbox:item-will-change
Это событие генерируется непосредственно перед изменением текущего элемента в лайтбоксе. Оно будет содержать currentItem (тот, который вот-вот изменится).
lightbox:item-did-change
Это событие генерируется непосредственно после изменения элемента в лайтбоксе и завершения его загрузки, и оно будет содержать currentItem в качестве аргумента.
lightbox:closed
Это событие генерируется непосредственно после закрытия лайтбокса и не имеет аргументов.
С помощью вышеуказанных событий теоретический компонент темы может легко добавить аналитику для лайтбокса следующим образом:
api.onAppEvent('lightbox:opened', ({items, currentItem}) => {
console.log({items});
console.log({currentItem});
// ваш код аналитики здесь
});
или другие подобные идеи.
CSS
Новый лайтбокс использует соглашение об именовании BEM для HTML-классов. Вот полный список селекторов, которые вы можете использовать:
html.has-lightbox {
// css для элемента HTML, когда лайтбоксы открыты
}
.d-lightbox {
&--is-visible {
// Основной элемент лайтбокса
}
&__content {
// Обертка внутреннего содержимого лайтбокса
}
}
.d-lightbox {
&__content__header {
// Заголовок лайтбокса
}
}
.d-lightbox {
&__content__body {
// Тело лайтбокса (содержит основное изображение)
&__backdrop {
// Фон лайтбокса
}
&__main-image {
// Основное изображение лайтбокса
}
&__error-message {
// Сообщение об ошибке лайтбокса
}
&__previous-button,
&__next-button {
// Основные кнопки предыдущего/следующего в лайтбоксе
}
}
}
.d-lightbox {
&__content__footer {
// Нижний колонтитул лайтбокса
&__main-title {
// Заголовок изображения лайтбокса
&__item-file-details {
// Детали файла лайтбокса, например "1000x582 183KB"
}
}
}
}
.d-lightbox {
&__content__carousel {
// Контейнер карусели лайтбокса
&__previous-button,
&__next-button {
// Кнопки предыдущего/следующего в карусели лайтбокса
}
}
}
.d-lightbox {
&__content__carousel {
&__carousel-items {
// Контейнер элементов карусели лайтбокса
&__item,
&__item--is-current {
// Элемент карусели лайтбокса
}
&__item--is-current {
// Текущий элемент карусели лайтбокса
}
}
}
}
.d-lightbox {
&--is-vertical &__content__carousel {
// Вертикальные стили карусели лайтбокса
}
}
.d-lightbox {
&--is-horizontal &__content__carousel {
// Горизонтальные стили карусели лайтбокса
}
}
.d-lightbox {
.btn-flat {
// Стили для всех кнопок лайтбокса
}
}
.d-lightbox {
&__content {
&__focus-trap,
&__screen-reader-announcer {
// Ловушка фокуса и анонсер для экранного чтения лайтбокса. Они находятся за пределами экрана
}
}
}
/* Стили состояний */
// Карусель
.d-lightbox {
&--has-carousel {
// Стили лайтбокса, когда открыта карусель
}
}
// Расширенный заголовок
.d-lightbox {
&--has-expanded-title {
// Стили лайтбокса, когда заголовок развернут
}
}
// Масштабирование
.d-lightbox {
&--can-zoom {
// Стили лайтбокса, когда изображение можно масштабировать
}
&--is-zoomed {
// Стили лайтбокса, когда изображение увеличено
}
}
// Вращение
.d-lightbox {
&--is-rotated {
// Стили лайтбокса, когда изображение повернуто
}
}
// Полный экран
.d-lightbox {
&--is-fullscreen {
// Стили лайтбокса, когда изображение в полном экране
}
}
Теперь, когда мы рассмотрели что это, мы можем наконец перейти к…
Когда это будет
Сейчас PR готов к ревью, которое должно произойти в первую очередь. После прохождения ревью он станет доступен для сайтов, которые обновятся. PR добавляет новую временную настройку сайта, так как мы переходим с Magnific Popup на Discourse Lightbox. Название настройки:
enable_experimental_lightbox
Если настройка отключена, PR не окажет никакого эффекта, и всё будет работать как раньше с Magnific Popup.
Discourse Lightbox заменит Magnific Popup в обработанных сообщениях, сообщениях чата и компонентах загрузчика изображений, когда настройка будет включена.
Дорожная карта
- Ревью PR
- Мерж PR
- Окно для общих отзывов (1-2 недели)
- PR для удаления Magnific Popup из ядра и удаления экспериментальной настройки сайта.
- TBD: дополнительные цели, такие как компонент темы, исследующий различные макеты (это должно быть просто, поскольку новый лайтбокс использует CSS Grid для разметки).
Благодарности
- Эта работа была щедро спонсирована CDCK

- Огромная любовь
Дмитрию Семёнову, создателю Magnific Popup, за создание чего-то, что опередило своё время. - Изображения, использованные в демо выше, любезно предоставлены Ириной Ирисер @pexels




