Модальное окно предварительного просмотра темы

:information_source: Описание Модальное окно предварительного просмотра тем — открывайте и взаимодействуйте с темами, не покидая список тем
:eyeglasses: Предпросмотр Theme Creator
:hammer_and_wrench: Репозиторий GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub
:question: Руководство по установке Как установить тему или компонент темы
:open_book: Новичок в темах Discourse? Руководство для начинающих по использованию тем Discourse

Установить этот компонент темы

Модальное окно предварительного просмотра тем — открывайте и взаимодействуйте с темами, не покидая список тем

Я создал новый компонент темы Discourse под названием Topic Preview Modal.

Идея довольно проста:

Открывать тему прямо из списка тем в нативном модальном окне Discourse, читать и взаимодействовать с темой, а затем продолжать просмотр списка, не покидая его.

Всё началось с Facebook-style Topic Modal - Is it better?, но в итоге потребовалось довольно глубокое взаимодействие с системами Discourse: темами, потоком постов, редактором, модальными окнами, закладками, маршрутизацией, присутствием, отслеживанием прочитанного и предзагрузкой.


Зачем?

Стандартный рабочий процесс в Discourse выглядит так:

  1. Вы просматриваете список тем.
  2. Вы нажимаете на тему.
  3. Discourse переходит по адресу /t/....
  4. Вы читаете/отвечаете/взаимодействуете с темой.
  5. Вы возвращаетесь в список тем.

Для многих рабочих процессов этого вполне достаточно.

Однако при просмотре загруженного списка тем иногда мне нужно только быстро проверить тему, прочитать несколько постов, посмотреть последние ответы, отреагировать на что-то или ответить на быстрый вопрос.

Для такого случая покидать список тем кажется излишним.

Поэтому цель этого компонента заключалась в том, чтобы список тем вел себя больше как почтовый ящик:

список тем → предпросмотр → взаимодействие → закрытие → продолжение ровно с того места, где вы остановились.


Что он делает

Предпросмотр — это не просто статический отрывок.

Он отображает реальные компоненты постов Discourse внутри нативного DModal.

Это означает, что пользователи могут:

  • читать посты
  • прокручивать тему
  • загружать более ранние посты
  • загружать больше постов ниже
  • реагировать на посты
  • добавлять посты в закладки
  • цитировать текст
  • отвечать на тему
  • отвечать на отдельные посты
  • редактировать посты, если разрешено
  • удалять/восстанавливать посты, если разрешено
  • жаловаться на посты
  • просматривать историю постов
  • выполнять различные стандартные действия с постами
  • видеть присутствие в теме
  • переходить по ссылкам на другие посты в той же теме
  • переходить непосредственно к нужному посту
  • открывать полную тему при необходимости

Намерение состоит в том, чтобы предпросмотр ощущался максимально похоже на реальное открытие темы.


Два режима запуска

Есть два способа открыть предпросмотр.

1. Вся строка списка тем

Это значение по умолчанию.

Вся строка списка тем становится кликабельной, при этом общие интерактивные элементы, такие как:

  • карточки пользователей
  • участники
  • ссылки на категории
  • теги
  • ссылки на статус темы
  • массовое выделение

исключаются из запуска модального окна.

Это делает опыт очень быстрым при просмотре списка тем.

2. Явная кнопка разворачивания

В качестве альтернативы компонент может отображать небольшую иконку разворачивания через плагин Discourse. Пользовательские темы могут просто создать новый <PluginOutlet />, чтобы показать триггер.

В этом режиме стандартное поведение списка тем остается полностью нетронутым.

Пользователь нажимает на иконку разворачивания, чтобы открыть предпросмотр, в то время как нажатие на заголовок темы по-прежнему выполняет стандартную навигацию Discourse.

Это полезно, если сайт хочет сохранить стандартную модель взаимодействия со списком тем.

Настройка выглядит так:

trigger_style:
  row

или:

trigger_style:
  button

При использовании режима кнопки сам outlet также настраивается.


Предпросмотр начинается с позиции непрочитанного пользователя

Один из важных моментов заключается в том, что модальное окно не просто загружает первый пост.

Когда тема уже была частично прочитана, предпросмотр вычисляет:

last_read_post_number + 1

и открывается вокруг этого поста.

Так что если в теме 200 постов, и пользователь прочитал до поста #165, открытие предпросмотра начинается примерно с #166.

Это делает предпросмотр гораздо более полезным для реального просмотра.

Это также означает, что компонент должен обрабатывать обе стороны потока постов:

  • загрузку более ранних постов при необходимости
  • загрузку новых постов ниже

Кнопка Более ранние посты отображается, когда есть посты выше текущей загруженной области, в то время как sentinel IntersectionObserver автоматически загружает больше постов, когда пользователь достигает дна.


Предзагрузка

Одной из самых больших частей компонента является его система предзагрузки.

Проблема с таким модальным окном заключается в том, что пользователь ожидает, что оно будет открываться мгновенно.

Если мы начинаем загрузку темы только после нажатия пользователем, модальное окно все равно может тратить заметное время на ожидание сети.

Вместо этого компонент может проактивно предзагружать темы, пока пользователь просматривает список.

Когда строка темы приближается к области просмотра, IntersectionObserver может запланировать предзагрузку.

Существует несколько защитных механизмов, чтобы предотвратить превращение этого в неконтролируемый фоновый трафик.

Задержка (Debouncing)

Тема не запускает запрос немедленно, просто потому что она на мгновение появилась в области просмотра.

Компонент ждет установленный период задержки.

По умолчанию:

400 мс

Это особенно полезно при быстрой прокрутке длинного списка тем.

Отступ корня (Root margin)

Предзагрузка может начинаться немного раньше, чем тема фактически входит в область просмотра.

По умолчанию:

50 пкс

Это дает запросу небольшой опережающий старт.

Лимит одновременных запросов

Количество одновременных предзагрузок ограничено.

По умолчанию:

2

Настройка позволяет от 1 до 6 одновременных предзагрузок.

Лимит в минуту

Существует также второй механизм защиты:

max_prefetches_per_minute

По умолчанию:

15

Так что даже если пользователь продолжает прокручивать сотни тем, компонент не будет непрерывно генерировать спекулятивные запросы.

0 отключает лимит.

Предзагрузку можно полностью отключить

Если сайт не хочет никакого спекулятивного сетевого трафика:

enable_prefetch = false

Компонент продолжает работать нормально. Темы просто загружаются при открытии предпросмотра.


Данные предзагрузки хранятся отдельно от стандартной навигации по темам

Здесь есть важный нюанс реализации.

Предзагруженный ответ не сразу записывается в стандартный ключ предварительной загрузки topic_<id> Discourse.

Вместо этого компонент использует собственное пространство имен:

topic-preview-modal:prefetch:<topicId>

Только когда пользователь фактически открывает предпросмотр, предзагруженный промис повышается до основного ключа предварительной загрузки темы.

Это сделано намеренно.

Предпросмотр может загружать тему, начиная с last_read_post_number + 1, и я не хочу, чтобы этот специфичный для предпросмотра ответ просачивался в стандартную навигацию по маршруту темы.

Так что жизненный цикл выглядит примерно так:

тема входит в область просмотра
        ↓
предзагрузка
        ↓
приватное хранилище предварительной загрузки
        ↓
пользователь открывает предпросмотр
        ↓
повышение уровня предварительной загрузки
        ↓
Topic.find()/PostStream использует тот же промис

Это также означает, что модальному окну не нужно ждать завершения запроса предзагрузки перед открытием.

Модальное окно может открыться немедленно со своим скелетом, в то время как тот же промис продолжает разрешаться.


Поддержка мобильных устройств

На самом деле, это была одна из причин, почему я потратил значительно больше времени на реализацию.

Первоначальная идея работала достаточно хорошо на десктопе, но мобильные устройства выявили несколько проблем, связанных с:

  • касаниями
  • прокруткой модального окна
  • фокусом
  • вложенными меню
  • редактором
  • видимостью постов
  • загрузкой изображений
  • производительностью

Итоговая реализация поэтому избегает отношения к модальному окну как к совершенно отдельному миниатюрному форуму.

Вместо этого он использует как можно больше существующей инфраструктуры Discourse.


Реальные компоненты постов Discourse

Модальное окно не воссоздает посты, используя упрощенный пользовательский шаблон.

Оно отображает реальные компоненты Discourse:

Post
PostSmallAction

Это важно, потому что в противном случае предпросмотр быстро превратился бы во вторую реализацию пользовательского интерфейса постов.

Компонент передает соответствующие действия в стандартные компоненты постов, включая такие вещи, как:

  • ответ
  • редактирование
  • удаление
  • восстановление
  • жалоба
  • история
  • закладки
  • вики
  • блокировка/разблокировка
  • тип поста
  • изменения владения
  • значки
  • скрытые посты
  • цитирование
  • и т.д.

В результате предпросмотр может вести себя гораздо больше как обычная тема, чем традиционный компонент «предпросмотра».


Ответы и редактор

Редактор — одна из более сложных частей.

Предпросмотр может открыть стандартный редактор Discourse для:

Ответа на тему

Редактор темы открывается с моделью темы и правильной информацией о черновике.

Ответа на конкретный пост

Пост передается в редактор, чтобы ответ вел себя как обычный ответ на пост.

Цитирования выделенного текста

Компонент также интегрируется с PostTextSelection.

Это означает, что пользователи могут выделять текст внутри предпросмотра и использовать стандартный поток цитирования/ответа Discourse.


Вложенные модальные окна

Еще одной сложной частью была система модальных окон Discourse.

Посты могут открывать другие модальные окна и диалоги:

  • жалобы
  • история
  • диалоги, связанные со значками
  • изменения владения
  • подтверждения удаления
  • и т.д.

Если бы им позволяли взаимодействовать с глобальным сервисом модальных окон в обычном режиме, открытие одного из них могло закрыть весь предпросмотр темы.

Чтобы избежать этого, компонент создает локальный механизм подмодальных окон.

Концептуально:

Модальное окно предпросмотра темы
        │
        ├── Модальное окно жалобы
        ├── Модальное окно истории
        ├── Подтверждение удаления
        ├── Модальное окно значков
        └── другие модальные окна, связанные с постами

Предпросмотр остается смонтированным под ними.

Компонент временно переопределяет соответствующие методы сервиса модальных окон, пока он активен, и восстанавливает их при уничтожении.


Маршрутизация внутри модального окна

Еще один важный нюанс — ссылки на посты в той же теме.

Например, если пост содержит ссылку на:

/t/my-topic/123

предпросмtru не нужно закрывать и уходить.

Вместо этого компонент перехватывает навигацию по той же теме и переходит к запрошенному посту внутри модального окна.

То же самое относится к ссылкам, указывающим на тему без конкретного номера поста.

Это удерживает пользователя внутри предпросмотра.

Если ссылка указывает на действительно другую тему, компонент сначала восстанавливает свои временные патчи сервисов и закрывается, прежде чем разрешить стандартный переход маршрута Discourse.

Эта очистка важна, потому что в противном случае подписки предпросмотра и таймер могли бы оставаться активными, пока инициализируется реальный маршрут темы.


Отслеживание прочитанного и времени

n
Я также хотел, чтобы предпросмотр вел себя корректно с точки зрения Discourse.

Открытие предпросмотра не должно означать, что отслеживание прочитанного полностью обходится.

Поэтому компонент обрабатывает:

  • отслеживание посещений темы
  • отслеживание видимых постов
  • время в теме
  • обновления последнего прочитанного поста

Таймер использует IntersectionObserver для определения, какие посты действительно видны.

Каждые 5 секунды время видимых постов сбрасывается в:

/topics/timings

Когда модальное окно закрывается, выполняется один финальный сброс, чтобы последние несколько секунд не были потеряны.

Реализация также ограничивает один интервал времени 60 секундами.


Синхронизация состояния непрочитанного в списке тем

n
Здесь была еще одна тонкая проблема.

Обновления состояния отслеживания тем Discourse недостаточно для обновления значка непрочитанных, отображаемого непосредственно на строке списка тем.

Поэтому компонент обновляет фактический объект темы, связанный со строкой, после сброса информации о времени.

Он обновляет такие значения, как:

last_read_post_number
unread_posts
unread
new_posts

при необходимости.

Это означает, что после прочтения темы внутри модального окна список тем может немедленно отразить новое состояние прочитанного, вместо того чтобы требовать полной перезагрузки страницы.


Видимость постов

Предпросмотр использует общий IntersectionObserver для определения, когда отдельные посты становятся видимыми.

Также существует синхронная проверка видимости при подключении наблюдателя.

Это обрабатывает крайний случай, когда пост уже виден при монтировании, но асинхронный первый обратный вызов IntersectionObserver еще не сработал.

Это особенно актуально для очень коротких тем, где вся тема может быть уже видна при открытии модального окна.


Соображения производительности

Одной из главных целей было избежать превращения модального окна в ресурсоемкую миниатюрную страницу темы.

Для этого делается несколько вещей.

Постепенный рендеринг

Начальная загрузка не отображает каждый пост немедленно.

Компонент сначала отображает достаточно постов, чтобы достичь целевой позиции.

Оставшиеся посты затем рендерятся постепенно с использованием:

requestIdleCallback

если доступно, с fallback на setTimeout.

Это особенно полезно при открытии длинной темы вокруг поста, находящегося далеко внизу потока.

CSS-контейнеризация

Посты используют:

contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;

Это позволяет браузеру избегать ненужной работы по рендерингу постов, которые в данный момент не видны.

Ленивые изображения

n
Изображениям, которые еще не указали режим загрузки, автоматически присваиваются:

loading="lazy"
decoding="async"

Это предотвращает немедленную загрузку всего содержимого в длинной теме с множеством изображений.


Состояние загрузки

Модальное окно не просто показывает пустую белую область, пока выполняется запрос.

У него есть скелетный интерфейс с:

  • заглушками для аватаров
  • заглушками для имен пользователей
  • заглушками для текста постов
  • анимацией мерцания

Мерцание учитывает:

prefers-reduced-motion

так что анимация отключена для пользователей, которые запросили уменьшенное движение.


Стабильность позиции прокрутки

n
Есть несколько мест, где компоненту нужно вручную манипулировать позицией прокрутки.

Например, при загрузке более ранних постов newly inserted content увеличивает высоту прокрутки.

Простое добавление постов в начало заставило бы текущую позицию пользователя прыгнуть.

Поэтому компонент записывает предыдущую высоту прокрутки и компенсирует разницу после вставки постов.

Это сохраняет видимое содержимое примерно в том же месте.

То же самое относится к переходу к конкретному посту.

Компонент выполняет шаг позиционирования после рендеринга и проверяет позицию снова в последующих кадрах, чтобы учесть содержимое, которое все еще может оседать.


Присутствие в теме

n
Когда доступны соответствующие данные темы, предпросмотр также может отображать информацию о присутствии в теме Discourse внизу модального окна.

Так что пользователи могут видеть, кто еще в данный момент просматривает тему, не покидая предпросмотр.


Взаимодействие с мобильными меню и фокусом

n
Мобильные устройства привнесли еще одну категорию проблем.

Некоторые элементы пользовательского интерфейса Discourse используют общие сервисы модальных окон/меню, и эти сервисы не обязательно знают, что предпросмотр темы в данный момент действует как вложенный контекст просмотра.

Поэтому у компонента есть дополнительная обработка вокруг:

  • modal.close()
  • меню Float Kit
  • восстановления фокуса
  • редактора
  • клавиатурных управления lightbox
  • блокировок прокрутки body

Например, если меню внутренне пытается вызвать глобальный метод закрытия модального окна, это не должно случайно закрыть весь предпросмотр темы.

Аналогично, когда редактор открыт, фокус должен оставаться внутри редактора, а не вытягиваться обратно в контекст фокуса предпросмотра.


Настройка

n
Компонент в настоящее время предоставляет следующие настройки:

Настройка По умолчанию Описание
trigger_style row Сделать всю строку кликабельной или использовать явную кнопку
plugin_outlet topic-list-after-title Outlet, используемый триггером кнопки
enable_prefetch true Включить/отключить фоновую предзагрузку тем
max_concurrent_prefetches 2 Максимальное количество одновременных запросов предзагрузки
prefetch_debounce_ms 400 Задержка перед началом предзагрузки
prefetch_root_margin_px 50 Начинать предзагрузку за это количество пикселей до входа строки в область просмотра
max_prefetches_per_minute 15 Максимальное количество спекулятивных запросов в минуту

Управление предзагрузкой намеренно настраивается, потому что у разных сообществ могут быть очень разные паттерны трафика и характеристики хостинга/сети.


Одна из главных целей дизайна: не ломать стандартный Discourse

n
Я старался держать компонент максимально близко к существующей архитектуре Discourse.

Он не реализует свой собственный рендерер постов, свой собственный редактор, свою собственную модель темы или свой собственный совершенно отдельный поток постов.

Вместо этого он создает временный контекст просмотра вокруг существующих компонентов и сервисов Discourse.

Это также причина, почему некоторые части реализации более сложны, чем могут показаться на первый взгляд.

Более интересным вызовом было:

Может ли тема вести себя почти как обычная тема Discourse, когда она фактически отображается внутри другого контекста пользовательского интерфейса?

Это потребовало работы с границами между глобальными сервисами Discourse и локальным предпросмотром.

5 лайков

К вашему сведению:

У него есть проблемы с математикой. Но это может быть ещё одним частным случаем.

1 лайк

Абсолютная легенда :slight_smile: теперь нужно разобраться, как заставить это работать в моей конфигурации :slight_smile: @awesomerobot от чего зависит твоя тема для клика по всей строке

api.renderInOutlet("topic-list-before-link", TopicListItemClick);
2 лайка

Для всех, кто использует тему Reddit-ish, вот исправление, которое у меня заработало.

Совместимость с темой Reddit-ish

Примечание для всех, кто использует тему Reddit-ish: сама кнопка модального окна работает, но стандартный триггер строки — нет.

Проблема в том, что Reddit-ish заменяет стандартное поведение строк списка тем и обрабатывает клики по всей карточке темы. Из-за этого обычная обработка клика по строке для модального окна не работает как положено.

Изменение настройки Topic Preview Modal на:

Trigger style: button
Plugin outlet: topic-list-after-title

работает корректно, потому что Reddit-ish уже включает outlet topic-list-after-title.

Чтобы сохранить поведение клика по всей карточке, я оставил Topic Preview Modal в режиме кнопки и изменил существующее действие openTopic() в Reddit-ish так, чтобы оно запускало рабочую кнопку модального окна.

Оригинальное действие Reddit-ish:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
  } else {
    navigateToTopic(topic, topic.lastUnreadUrl);
  }
}

Я изменил его на:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper") ||
    event.target.closest(".topic-preview-modal__trigger-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
    return;
  }

  const previewButton = event.currentTarget.querySelector(
    ".topic-preview-modal__trigger-wrapper--button"
  );

  if (previewButton) {
    event.preventDefault();
    event.stopPropagation();
    previewButton.click();
    return;
  }

  navigateToTopic(topic, topic.lastUnreadUrl);
}

Триггер кнопки модального окна рендерится как:

<div class="topic-preview-modal__trigger-wrapper">
  <span
    role="button"
    class="topic-preview-modal__trigger-wrapper--button"
  >

Таким образом, это не воссоздает логику модального окна. Это просто заставляет клик по карточке Reddit-ish запускать существующую рабочую кнопку предпросмотра.

Результат:

  • Клик по карточке темы открывает модальное окно предпросмотра.

  • Клик по заголовку темы открывает модальное окно предпросмотра.

  • Кнопка предпросмотра по-прежнему работает.

  • Cmd/Ctrl+клик по-прежнему открывает обычную тему в новой вкладке.

  • Ссылки на категории и другие обычные ссылки продолжают работать нормально.

  • Если кнопка предпросмотра отсутствует, Reddit-ish возвращается к стандартной навигации по темам.

Таким образом, базовое модальное окно отлично работает с Reddit-ish; несовместимость касается исключительно стандартного триггера строки.

Я также скрыл кнопку, используя:

.topic-preview-modal__trigger-wrapper {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}
2 лайка

Теперь нужно попробовать заставить вложенные ответы работать в модальном окне :slight_smile:

1 лайк