主题预览模态框

安装此主题组件

主题预览模态框 – 无需离开主题列表即可打开并与主题互动

我创建了一个名为 主题预览模态框 的新 Discourse 主题组件。

其理念相当简单:

直接在主题列表中以原生 Discourse 模态框的形式打开主题,阅读并与主题互动,然后继续浏览列表,而无需导航离开当前页面。

灵感源自 Facebook-style Topic Modal - Is it better? ,但最终实现需要与 Discourse 的主题、帖子流、编辑器、模态框、书签、路由、在线状态、阅读跟踪和预加载系统进行大量集成。


为什么?

Discourse 的正常流程如下:

  1. 你正在浏览主题列表。
  2. 你点击某个主题。
  3. Discourse 导航至 /t/...
  4. 你阅读/回复/与该主题互动。
  5. 你返回主题列表。

对于许多工作流程来说,这完全没问题。

然而,在浏览繁忙的主题列表时,有时我只想快速查看某个主题,阅读几篇帖子,检查最新回复,对某事做出反应,或回答一个简单的问题。

对于这种用例,离开主题列表感觉成本过高。

因此,该组件的目标是让主题列表的行为更像是一个 收件箱

主题列表 → 预览 → 互动 → 关闭 → 继续之前的位置。


它的作用

预览不仅仅是静态摘要。

它在原生 DModal 中渲染实际的 Discourse 帖子组件。

这意味着用户可以:

  • 阅读帖子
  • 滚动浏览主题
  • 加载更早的帖子
  • 加载下方更多帖子
  • 对帖子做出反应
  • 书签帖子
  • 引用文本
  • 回复主题
  • 回复单个帖子
  • 在允许时编辑帖子
  • 在允许时删除/恢复帖子
  • 标记帖子
  • 查看帖子历史
  • 执行各种常规帖子操作
  • 查看主题在线状态
  • 跟随链接到同一主题内的其他帖子
  • 直接跳转到相关帖子
  • 在需要时打开完整主题

其意图是让预览的感觉尽可能接近实际打开主题。


两种触发模式

有两种方式可以打开预览。

1. 整个主题列表行

这是默认设置。

整个主题列表行变为可点击,而常见的交互元素如:

  • 用户卡片
  • 参与者
  • 分类链接
  • 标签
  • 主题状态链接
  • 批量选择

则排除在模态框触发器之外。

这使得在浏览主题列表时的体验非常快速。

2. 显式展开按钮

或者,该组件可以通过 Discourse 插件出口渲染一个小展开图标。自定义主题可以简单地创建一个新的 <PluginOutlet /> 来显示触发器。

在此模式下,正常的主题列表行为保持完全不变。

用户点击展开图标打开预览,而点击主题标题仍执行正常的 Discourse 导航。

如果站点希望保留标准的主题列表交互模型,这很有用。

设置如下:

trigger_style:
  row

或者:

trigger_style:
  button

使用按钮模式时,出口也可配置。


预览从用户的未读位置开始

一个重要细节是,模态框 不会仅仅加载第一篇文章

当主题已被部分阅读时,预览会计算:

last_read_post_number + 1

并围绕该帖子打开。

因此,如果一个主题有 200 篇帖子,而用户已读到第 165 篇,打开预览将从第 166 篇左右开始。

这使得预览在实际浏览中更有用。

这也意味着组件必须处理帖子流的两端:

  • 必要时加载更早的帖子
  • 加载下方更多帖子

当当前加载范围上方有帖子时,会显示 更早的帖子 按钮,而 IntersectionObserver 哨兵会在用户到达底部时自动加载更多帖子。


预加载

该组件最大的部分之一是其预加载系统。

像这样的模态框的问题是,用户希望它感觉是瞬间完成的。

如果仅在用户点击后才开始加载主题,模态框仍可能花费明显的时间等待网络。

相反,该组件可以在用户浏览列表时主动预加载主题。

当主题行接近视口时,IntersectionObserver 可以调度预加载。

有几个保障措施防止这变成不受控制的后台流量。

防抖

主题不会仅仅因为短暂出现在视口中就立即触发请求。

组件会等待配置的防抖期。

默认值:

400 ms

这在快速滚动长主题列表时特别有用。

根边距

预加载可以在主题实际进入视口之前稍微开始。

默认值:

50 px

这为请求提供了一点提前量。

并发请求限制

同时预加载的数量是有限的。

默认值:

2

设置允许 1 到 6 个并发预加载。

每分钟预算

还有第二个保护机制:

max_prefetches_per_minute

默认值为:

15

因此,即使用户继续滚动浏览数百个主题,组件也不会持续生成推测性请求。

0 禁用限制。

预加载可以完全禁用

如果站点不希望有任何推测性网络流量:

enable_prefetch = false

组件继续正常工作。主题仅在打开预览时加载。


预加载数据与正常主题导航分开

这里有一个重要的实现细节。

预加载的响应 不会立即写入 Discourse 的正常 topic_<id> 预加载键

相反,组件使用自己的命名空间:

topic-preview-modal:prefetch:<topicId>

仅当用户实际打开预览时,预加载的承诺才会提升为核心主题预加载键。

这是有意为之。

预览可能从 last_read_post_number + 1 开始加载主题,我不希望这种预览特定的响应泄漏到正常的主题路由导航中。

因此,生命周期本质上是:

主题进入视口
        ↓
预加载
        ↓
私有预加载存储
        ↓
用户打开预览
        ↓
提升预加载
        ↓
Topic.find()/PostStream 使用相同的承诺

这也意味着模态框无需等待预加载请求完成即可打开。

模态框可以立即以其骨架界面打开,而相同的承诺继续解析。


移动设备支持

这实际上是我花 considerably 更多时间进行实现的原因之一。

初始想法在桌面上工作得相当好,但移动设备暴露了以下方面的问题:

  • 触摸交互
  • 模态框滚动
  • 焦点
  • 嵌套菜单
  • 编辑器
  • 帖子可见性
  • 图像加载
  • 性能

因此,最终实现避免将模态框视为完全独立的微型论坛。

相反,它尽可能重用 Discourse 现有的基础设施。


真实的 Discourse 帖子组件

模态框不使用简化的自定义模板重新创建帖子。

它渲染 Discourse 的实际:

Post
PostSmallAction

组件。

这很重要,因为否则预览很快就会变成帖子 UI 的第二种实现。

组件将相关操作传递给正常的帖子组件,包括:

  • 回复
  • 编辑
  • 删除
  • 恢复
  • 标记
  • 历史
  • 书签
  • 维基
  • 锁定/解锁
  • 帖子类型
  • 所有权变更
  • 徽章
  • 隐藏帖子
  • 引用
  • 等等。

结果是,预览可以表现得比传统的“预览”组件更像正常的主题。


回复和编辑器

编辑器是更复杂的部分之一。

预览可以为以下情况打开正常的 Discourse 编辑器:

回复主题

主题编辑器使用主题模型和正确的草稿信息打开。

回复特定帖子

帖子传递给编辑器,以便回复表现得像正常的帖子回复。

引用选中文本

组件还与 PostTextSelection 集成。

这意味着用户可以在预览中选择文本并使用 Discourse 的正常引用/回复流程。


嵌套模态框

另一个棘手的部分是 Discourse 的模态框系统。

帖子可以打开其他模态框和对话框:

  • 标记
  • 历史
  • 徽章相关对话框
  • 所有权变更
  • 删除确认
  • 等等。

如果允许它们正常与全局模态框服务交互,打开其中一个可能会关闭整个主题预览。

为了避免这种情况,组件创建了一个本地子模态框机制。

概念上:

主题预览模态框
        │
        ├── 标记模态框
        ├── 历史模态框
        ├── 删除确认
        ├── 徽章模态框
        └── 其他帖子相关模态框

预览在下方保持挂载。

组件在活动期间临时修补相关的模态框服务方法,并在销毁时恢复它们。


模态框内的路由

另一个重要细节是同一主题内帖子的链接。

例如,如果帖子包含指向:

/t/my-topic/123

的链接,

预览无需关闭并导航离开。

相反,组件拦截同一主题导航并在模态框内跳转到请求的帖子。

这也适用于指向主题但没有特定帖子编号的链接。

这使用户保持在预览内。

如果链接指向真正不同的主题,组件首先恢复其临时服务修补并关闭自身,然后允许正常的 Discourse 路由转换。

这种清理很重要,因为否则预览的订阅和时间跟踪器可能在真实主题路由初始化时保持活跃。


阅读跟踪和时间跟踪

我还希望预览从 Discourse 的角度正确表现。

打开预览不应意味着完全绕过阅读跟踪。

因此,组件处理:

  • 主题访问跟踪
  • 可见帖子跟踪
  • 主题计时
  • 最后阅读帖子更新

计时跟踪器使用 IntersectionObserver 确定哪些帖子实际可见。

每 5 秒,可见帖子计时刷新到:

/topics/timings

当模态框关闭时,执行最后一次刷新,以便最后几秒不会丢失。

实现还将单个时间间隔上限设为 60 秒。


保持主题列表未读状态同步

这里还有另一个微妙的问题。

仅更新 Discourse 的主题跟踪状态不足以更新主题列表行上直接显示的未读徽章。

因此,在计时信息刷新后,组件更新与行关联的实际主题对象。

它更新诸如:

last_read_post_number
unread_posts
unread
new_posts

的值(如适用)。

这意味着在模态框内阅读主题后,主题列表可以立即反映新的阅读状态,而无需完全刷新页面。


帖子可见性

预览使用共享的 IntersectionObserver 确定单个帖子何时变为可见。

在观察者附加时还有同步可见性检查。

这处理了一个边缘情况,即帖子在挂载时已可见,但异步的第一个 IntersectionObserver 回调尚未触发。

这对于非常短的主题特别相关,其中整个主题可能在模态框打开时已可见。


性能考虑

主要目标之一是避免将模态框变成性能密集的微型主题页面。

为此采取了一些具体措施。

渐进式渲染

初始加载不会立即渲染每篇帖子。

组件首先渲染足够的帖子以达到目标位置。

剩余的帖子随后使用:

requestIdleCallback

(如果可用)渐进式渲染,回退到 setTimeout

这在打开长主题且目标帖子位于流深处时特别有用。

CSS 包含

帖子使用:

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

这允许浏览器避免对当前不可见的帖子进行不必要的渲染工作。

懒加载图像

尚未指定加载模式的图像自动获得:

loading="lazy"
decoding="async"

这防止包含许多图像的长主题立即加载所有内容。


加载状态

模态框在发出请求时不会仅显示空白白色/空区域。

它具有带有以下内容的骨架 UI:

  • 头像占位符
  • 用户名/名称占位符
  • 帖子正文占位符
  • 闪烁动画

闪烁尊重:

prefers-reduced-motion

因此,对于请求减少动画的用户,动画被禁用。


保持滚动位置稳定

有几个地方组件需要手动操作滚动位置。

例如,当加载更早的帖子时,新插入的内容增加了滚动高度。

简单地前置帖子会使用户的当前位置跳跃。

因此,组件记录之前的滚动高度,并在帖子插入后补偿差异。

这使当前可见的内容保持在大约相同的位置。

跳转到特定帖子时也是如此。

组件执行渲染后定位步骤,并在后续帧中再次验证位置,以考虑可能仍在稳定的内容。


主题在线状态

当相关主题数据可用时,预览还可以在模态框底部显示 Discourse 的主题在线状态信息。

因此,用户可以查看谁当前正在查看主题,而无需离开预览。


与移动菜单和焦点的交互

移动设备引入了另一类问题。

一些 Discourse UI 元素使用共享的模态框/菜单服务,这些服务不一定知道主题预览当前正作为嵌套浏览上下文运行。

因此,组件在以下方面具有额外处理:

  • modal.close()
  • Float Kit 菜单
  • 焦点恢复
  • 编辑器
  • 灯箱键盘控制
  • 主体滚动锁定

例如,如果菜单内部尝试调用全局模态框关闭方法,这不应意外关闭整个主题预览。

同样,当编辑器打开时,焦点需要保持在编辑器内,而不是被拉回预览的焦点上下文。


配置

组件目前公开以下设置:

设置 默认值 描述
trigger_style row 使整行可点击或使用显式按钮
plugin_outlet topic-list-after-title 按钮触发器使用的出口
enable_prefetch true 启用/禁用后台主题预加载
max_concurrent_prefetches 2 最大同时预加载请求
prefetch_debounce_ms 400 开始预加载前的延迟
prefetch_root_margin_px 50 在行进入视口之前这么多像素开始预加载
max_prefetches_per_minute 15 每分钟最大推测性请求

预加载控制有意可配置,因为不同社区可能有非常不同的流量模式和托管/网络特性。


主要设计目标之一:不要破坏正常的 Discourse

我尽量使组件尽可能接近 Discourse 现有的架构。

它不实现自己的帖子渲染器、自己的编辑器、自己的主题模型或自己完全独立的帖子流。

相反,它在 Discourse 现有的组件和服务周围构建了一个临时浏览上下文。

这也是为什么实现的某些部分比初看起来更复杂的原因。

更有趣的挑战是:

当主题实际显示在另一个 UI 上下文中时,它能否表现得几乎像正常的 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-list-after-title

即可正常工作,因为 Reddit-ish 已经包含了 topic-list-after-title 出口。

为了保留整张卡片的点击行为,我将“话题预览模态框”保留在按钮模式,并修改了 Reddit-ish 现有的 openTopic() 动作,使其触发模态框中可正常工作的按钮。

原始的 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 个赞