Discourse Lightbox - 迁移离开 Magnific popup

Discourse 允许用户在帖子(及其他位置)上传图片。有时这些图片过大无法直接显示,因此 Discourse 会生成一个缩放版本并添加到帖子中。当你点击该缩放图片时,会弹出一个精美的覆盖层,其中包含原始尺寸的图片——这通常被称为“灯箱”(lightbox)效果。

Discourse 目前使用一个名为 Magnific Popup 的库来处理灯箱行为。本主题旨在通过从 Magnific Popup 迁移到更符合当今用户和开发者期望的方案,对 Discourse 的灯箱功能进行全面升级。

为什么需要升级

Magnific 是一个非常优秀的库,其 项目页面 上的星标数量充分说明了它在过去多年中为无数用户和开发者解决了哪些问题。

它在易用性和功能特性方面也领先于时代。这一点显而易见:你今天在它身上看到的所有功能,早在 2014 年发布的 v1.0 版本中就已经存在了。

那么,这给我们带来了什么启示?我会简短说明:Magnific Popup 是为一个完全不同的世界设计的,那时的跨浏览器兼容性远不如今天。这意味着四点:

  1. 它依赖 jQuery
  2. 它包含为古老浏览器编写的代码
  3. 自那时起,访问网页的平均设备已发生巨大变化
  4. 它的设计优先考虑的是静态页面,而非像 Discourse 这样的单页应用(SPA)。这给单页应用带来了特定的性能问题,我们稍后会详细讨论。

鉴于当前 Web 标准的现状,以及使用原生 JavaScript 可以实现各种神奇效果,像 Discourse 这样的大型项目正在逐步摆脱对 jQuery 的依赖,这是可以理解的。请注意,关于 jQuery 的讨论在此话题中属于离题内容,此处提及主要是为了提供背景信息。

所以,这就是“为什么”……减少 jQuery 依赖、移除陈旧浏览器代码、提升整体性能,并为如今截然不同的平均设备提供更好的支持。

如何实现

市面上的解决方案层出不穷,关于“不要重复造轮子”的讨论也值得展开,但……我们暂且不谈这些,保持简洁。

或许解决这一痛点的最短方式就是:无论现成的方案多么贴合需求……都不如量身定制的方案那样完美契合。我们就此打住。

这里有几点需要考虑,无论是库的功能过剩(涵盖图片、iframe、视频等多种用例),还是许可证方面的模糊不清。

了解 Discourse 需要支持的具体用例,使我们能够避免不必要的代码,从而在不过度增加开销的前提下,添加更多针对性功能。

因此,我们现在有了一个相当扎实的基础。需求如下:

  1. 不依赖 jQuery
  2. 目前仅专注于图片
  3. 支持提升用户体验的功能,例如移动端的滑动操作
  4. 与 Discourse 集成,以便利用当前的主题/插件系统进行进一步的自定义和改进。

具体方案

Discourse 是一个 Ember 应用,因此新的灯箱组件必须是一个 Ember 组件,用于处理标记和数据。此外,由于用户期望能够在应用的任何位置设置灯箱,提供一个灯箱服务是非常合理的。这将允许开发者:

  1. 将服务注入到任何组件中,并将服务的设置/清理方法与组件生命周期关联起来
  2. 在应用的任何位置查找该服务,并调用其设置/清理方法

此外,为了提升用户体验,我们可以进行一些抽象,创建几个可在主题中使用的工具函数。稍后会详细介绍。

由于目标之一是允许主题/插件扩展功能,灯箱服务将通过 appEvents 通信状态变化。它将触发以下事件:

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

每个事件都将携带开发者通常期望的数据类型。例如,lightbox:opened 事件将包含所有项目的列表以及灯箱打开时的当前项目。稍后会详细说明。

好了,言归正传,我们继续。

在过去几周里,我一直在开发一个引入 Discourse Lightbox 的草案 PR。

乍一看,它可能显得有点庞大,让我们分解一下。

测试依赖大量用户交互的功能颇具挑战性,因此灯箱测试套件包含 63 个测试用例和 291 个断言。

在解决了测试规模问题后,让我们快速比较一下它与 Magnific Popup 的大小(均为未压缩版本):

Discourse Lightbox 代码行数 Magnific Popup 代码行数 差异
JavaScript 1197 1860 (35%)
CSS 813 351 131%
模板 401 0 -
总计 2411 2211 9.1%

因此,Discourse Lightbox 的代码量比 Magnific 多约 9%。当然,这只是一部分原因,原因有二:

  1. 我们尚未计算 jQuery,而 Magnific 依赖它。jQuery 3.6 的代码行数约为 11k。
  2. Discourse Lightbox 比 Magnific 提供了更多功能。

让我们详细看看这些功能。

以下视频中出现的任何卡顿都与录制过程有关,而非用户实际体验。所有动画/过渡将以稳定的 60FPS 运行。

话不多说,

基本布局

新 Discourse Lightbox 基本布局的视频 | video

作为对比,这是当前 Magnific 实现的相同效果:

当前 Discourse Lightbox 基本布局的视频 | video

关于上述视频的几点说明:

  1. 新灯箱将使用图片本身作为背景,而不是通用的半透明深色背景。图片自然会与自身相得益彰。请注意,这不会增加任何网络开销。用作背景的图片来自帖子正文,这意味着用户在打开灯箱时,该图片已被缓存。

  2. Discourse Lightbox 采用固定 UI。关闭按钮、标题和图片元数据不再附加在图片上,而是拥有独立的预留空间。这将有助于减少在不同尺寸图片之间切换时的跳动现象。

  3. 可以通过灯箱内的箭头或键盘快捷键在图片之间导航。 切换到下一张, 切换到上一张。在 RTL(从右到左)语言环境下,快捷键会相应反转。

移动端的布局非常相似,只是不显示箭头,因为添加了滑动手势用于导航。

新 Discourse Lightbox 在移动端的基本布局视频 | video

在移动端向左滑动切换到下一张,向右滑动切换到上一张。在 RTL 语言环境下,滑动手势会相应反转。

缩放

新灯箱提供了一个专用的缩放按钮,当查看的图片尺寸大于视口尺寸时可见。点击缩放按钮可放大图片,再次点击则缩小。此外,现在还有一个键盘快捷键用于缩放:Z。最后,如果图片可缩放,直接点击图片也会产生相同效果。

以下是演示这三种方法的视频:

演示新灯箱中三种缩放方法的视频 | video

请注意,虽然上述视频未演示,但当鼠标悬停在可缩放图片上时,光标会发生变化以反映这一状态。当悬停在已缩放的图片上时,光标还会变为 zoom-out 图标。

新灯箱中的缩放方式有所不同。在桌面上,缩放后的图片区域会跟随鼠标移动。

移动端没有悬停效果;它使用常规的触摸滚动来在缩放的图片中移动。

旋转

新灯箱添加了一个专用的旋转按钮,可每次旋转 90 度。旋转也有键盘快捷键:R。效果如下:

旋转和缩放可以组合使用。

全屏模式

新灯箱添加了一个全屏按钮。点击该按钮将使浏览器窗口进入全屏模式。键盘快捷键是 M

它会跟踪自身状态,当退出全屏模式或关闭灯箱时,会自动恢复到普通模式。

下载

当前灯箱在图片下方添加了一个“下载”链接。新灯箱也做了同样的事,但将其改为图标并放置在灯箱页脚。它仍然遵循相同的权限设置。如果启用了以下设置:

prevent_anons_from_downloading_images

且用户未登录,则不会显示下载图标。

新标签页

当前灯箱在图片下方添加了一个“原始”链接,用于在新标签页中打开图片。新灯箱也做了同样的事,但将其作为图标添加在灯箱头部。它同样遵循下载图标的权限设置。

图片标题

新灯箱主要聚焦于图片本身。图片标题默认截断为单行,但支持展开。示例如下:

有一个键盘快捷键用于展开/折叠标题:T。当图片被缩放或旋转时,标题将不显示。

轮播图

一个新功能是可以在轮播图中显示画廊中的所有图片。布局取决于设备屏幕,可以是水平或垂直的,效果如下:

有一个键盘快捷键用于切换轮播图:A

在移动端的效果如下:

在移动端向下滑动可切换轮播图的开启/关闭。

关闭

在桌面上,Esc 键仍像以前一样用于关闭灯箱。移动端现在增加了一个滑动手势:向上滑动即可关闭灯箱。

无障碍功能

除了基本的按钮标签外,新灯箱还在屏幕外添加了一个屏幕阅读器公告元素。当你在灯箱内导航到某张图片时,它会根据以下格式朗读其索引和标题:

image %{current} of %{total}: %{title}

这是一个简短示例:

新灯箱还通过 aria-hidden 移除了所有对屏幕阅读器无用的按钮。

请记住,无障碍功能是一项持续的任务,目前远未完成。总有改进的空间,但为了保持 v1 版本的简洁,我在此止步。

以上涵盖了新灯箱的所有功能。

接下来,让我们进入一些开发者相关的技术细节。

事件监听器

当前灯箱为每个烹饪帖子中的灯箱图片单独添加点击事件监听器。这意味着一个包含 20 张图片的帖子会有 20 个灯箱点击事件监听器。

新灯箱利用事件委托,仅在帖子本身添加一个事件监听器。

以下是匿名用户在隐身窗口中,对包含 20 张图片的帖子强制垃圾回收后,当前灯箱的事件监听器数量:

以下是同一帖子使用新灯箱后的情况:

此外,当前在灯箱内导航会添加事件监听器,这些监听器最终会变成孤儿并干扰垃圾回收。以下是图表:

  1. 加载一个包含 20 张图片的帖子的主题页面。
  2. 打开灯箱并连续三次遍历所有 20 张图片。
  3. 关闭灯箱。
  4. 强制浏览器垃圾回收。

当前灯箱:

新灯箱:

至于灯箱本身的事件监听器,Magnific 目前似乎并未清理它们(请记住,它并非为单页应用设计)。

关于上述测试的一个简短说明:它们非常基础,数字并非旨在“科学”,目标在于确定方向而非精确数值。

开发者说明

让我们谈谈如何使用新的 Discourse Lightbox 设置和清理灯箱。

设置和清理灯箱

开发者有两种选择。

  1. 通过以下方式将灯箱服务注入组件:

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

    然后你可以调用:

    this.lightbox.setupLightboxes({
      container: yourContainer // DOM 节点
      selector: ".css-selector" // 要启用灯箱的元素的 CSS 选择器字符串
    })
    

    之后,当你需要清理时,只需调用:

    this.lightbox.cleanupLightboxes()

    就这么简单。

  2. 如果你不想注入灯箱服务,可以像这样导入 setupLightboxescleanupLightboxes

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

    其余部分与注入服务相同。这两个函数会为你查找服务。因此:

    setupLightboxes({
     container: yourContainer // DOM 节点
     selector: ".css-selector" // 要启用灯箱的元素的 CSS 选择器字符串
    })
    
    //....
    
    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:opened
  • lightbox:item-will-change
  • lightbox:item-did-change
  • lightbox:closed

lightbox:opened

此事件在灯箱打开时触发,包含两个对象:

  1. items:这是当前灯箱中所有图片的数组。每个元素都是一个对象。
  2. 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 {
  // 灯箱打开时 HTML 元素的 CSS
}

.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。

路线图

  1. PR 审查
  2. PR 合并
  3. 一般反馈窗口(1-2 周)
  4. PR:从核心中移除 Magnific Popup 并移除实验性站点设置。
  5. 待定:扩展目标,例如探索不同布局的主题组件(由于新灯箱使用 CSS Grid 进行布局,这应该很简单)。

致谢

  • 这项工作由 CDCK 慷慨赞助 :pray:
  • Dmytro Semenov(Magnific Popup 的创作者)致以深深的敬意 :heart:,感谢他创造了一个远超时代的作品。
  • 上述演示中使用的图片由 Irina Iriser @pexels 提供。
38 个赞

我习惯了 YouTube 和其他桌面应用程序,按 F 键可以全屏。
有没有理由不使用肌肉记忆来设置快捷键?

4 个赞

这个看起来特别棒。 :raised_hands:

我希望的一点是,在使用箭头时能够跳转到下一张帖子的图片。这样,如果我正在浏览一个像“可爱的猫咪”这样的主题,我就可以直接使用箭头无缝地导航到下一张帖子图片。虽然需要考虑各种因素,但这会非常好。目前,我被迫关闭灯箱,转到下一篇帖子,点击第一张图片,…

16 个赞

bootstrap 3 也需要 jqwery。Discourse 使用 bootstrap 3。所以 jqwery 将作为依赖项安装在我们的论坛上。这是真的吗?

2 个赞

EmberJS 过去也曾需要 jQuery,而 Discourse 是一个 EmberJS 应用。我们(大约两年前)就开始致力于从 Discourse 本身、官方插件和组件中移除 jQuery 的使用。

这项工作量很大,但我们正在逐步接近目标。这个话题本身就是实现这一目标的众多努力之一。

11 个赞

这已实施。 :partying_face:

我们将在该主题中收集反馈一段时间,所以请查看并告诉我们您的想法。 :slight_smile: :+1:

6 个赞