主题预览弹窗

安装此主题组件

主题预览模态框 – 在不离开主题列表的情况下打开并交互主题

我创建了一个新的 Discourse 主题组件,名为 Topic Preview Modal(主题预览模态框)。

这个想法相当简单:

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

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


为什么?

Discourse 的正常流程是:

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

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

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

对于这种使用场景,离开主题列表感觉成本太高了。

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

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


它的功能

预览不仅仅是静态摘录。

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

这意味着用户可以:

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

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


两种触发模式

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

1. 整个主题列表行

这是默认设置。

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

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

则被排除在模态框触发范围之外。

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

2. 显式展开按钮

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

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

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

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

设置如下:

trigger_style:
  row

或:

trigger_style:
  button

如果使用按钮模式,出口(outlet)也是可配置的。


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

一个重要细节是,模态框并不是简单地加载第一个帖子

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

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>

只有当用户实际打开预览时,预取的 Promise 才会提升为核心主题预加载键。

这是有意为之的。

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

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

主题进入视口
        ↓
预取
        ↓
私有预加载存储
        ↓
用户打开预览
        ↓
提升预加载
        ↓
Topic.find()/PostStream 使用同一个 Promise

这也意味着模态框不必等待预取请求完成才能打开。

模态框可以立即以其骨架屏打开,同时同一个 Promise 继续解析。


移动端支持

这实际上是我在实现上花费更多时间的原因之一。

初始想法在桌面上工作得还算不错,但移动端暴露了围绕以下方面的问题:

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

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

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


真实的 Discourse 帖子组件

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

它渲染 Discourse 实际的:

Post
PostSmallAction

组件。

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

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

  • 回复
  • 编辑
  • 删除
  • 恢复
  • 举报
  • 历史
  • 收藏
  • Wiki
  • 锁定/解锁
  • 帖子类型
  • 所有权变更
  • 徽章
  • 隐藏帖子
  • 引用
  • 等等

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


回复和编辑器

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

预览可以为以下情况打开正常的 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 全局服务与本地预览之间的边界。

18 个赞

仅供参考:

它在数学方面存在问题。不过,这可能只是另一个边缘情况。

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;
}
3 个赞

现在尝试在模态框中实现嵌套回复功能 :slight_smile:

1 个赞

你,老兄,太棒了。这一定是我许久以来见过的最棒的主题组件。

唯一缺少的就是更宽/可配置的宽度,如果能让它和主题视图中的帖子宽度一样,那就太好了。

2 个赞

我将最大宽度调整为 800px

    .d-modal {
        --modal-max-width: 600px;
        --modal-width: 30em;
        --modal-min-width: 400px;
    }
1 个赞

这将适用于所有模态框……
我还做了这个,让图标不那么显眼

.topic-preview-modal__trigger-wrapper--button .d-icon {
    fill: #888;
}

@media (width >= 40rem) {
  .d-modal.topic-preview-modal {
      --modal-max-width: 800px;
  }
}
2 个赞

@Don 我很好奇:为什么你没有复用核心的 <PostList> 组件?

1 个赞

@Jagster,感谢你的报告!这是修复方案:FIX: Replace visibility:hidden to allow MathJax rendering · VaperinaDEV/discourse-topic-preview-modal@f9eb70f · GitHub


谢谢 @RGJ,我已经为此添加了一个设置项。


问得好。

PostList/PostListItem 是为基于摘要的列表(草稿、书签、活动流)设计的,而不是用于渲染主题的实际互动帖子流。

它通过 DDecoratedHtml 渲染 @post.excerpt/expandedExcerpt,外加头像/标题头部和展开至摘要的按钮。它没有点赞、引用、回复、编辑或举报操作——也就是模态框所需的那些互动帖子功能。

此外,PostList 的分页是单向的(fetchMorePosts 仅向下追加),而模态框需要在最后阅读的帖子处打开,并加载其前后相关的帖子——这需要真正的 postStream 服务的间隙加载功能,而不仅仅是一个简单的 fetch-more 回调。

因此,如果复用 PostList,意味着要在一个为不同用途构建的组件之上,重新实现 Post 的大部分互动行为以及 postStream 的双向加载功能。直接使用核心的 Post 组件和 postStream 则能确保模态框的行为与真实主题页面完全一致。

3 个赞

我已经让它与嵌套视图配合工作,需要更多的测试,可能还需要在后台添加一个选项。有几个人在使用这个功能,Don,它让人们更愿意请求更多内容,并在网站上停留更长时间,因为它非常流畅且易于滚动等。你在这方面做得非常出色

4 个赞

正在测试!界面改进非常令人印象深刻

2 个赞

这里急需提升,但看到这样的响应时间,我现在有点担心——你还活着吗 :laughing:

谢谢 :sign_of_the_horns:

3 个赞

疑问:为什么 .topic-avatarborder-top 最终会显示为滚动过程中保持可见的上一个帖子部分与当前帖子开头之间的分隔线?这个 border-top 的设计初衷真的是这样吗,还是存在某些定位/溢出规则导致它表现出这种行为?

@media (width >= 40rem) {
    .topic-avatar {
        border-top: 1px solid var(--content-border-color);
        padding-top: var(--space-4);
        width: var(--topic-avatar-width);
        float: left;
        z-index: 2;
        height: 100%;
        overflow-anchor: none;
    }
}
1 个赞

我觉得这在不想等待完整页面加载时快速浏览帖子可能会非常有用。如果有一个开关,让通知点击触发模态框而不是主题列表,那就太好了。

1 个赞

@Don,在试玩了这个超棒的组件24小时后,我最喜欢的功能是“下滑关闭”,因为我发现这样不需要移动拇指和手,感觉使用网站非常轻松。不过,在较长的话题中,向上滚回顶部体验并不理想。

为了解决这个问题,我在移动端添加了“右滑关闭”功能,这非常合理,而且对于想要快速滚动等操作的用户来说,效果简直太棒了(恕我直言)。你怎么看?

测试链接是 https://www.carptalk-online.co.uk/,当然,仅适用于移动端。

3 个赞

我试了试你的论坛,Damian,这个滑动操作真的非常完美。一如既往,做得很棒!

我想知道这个主题组件在 Horizon 中是否能正常工作?我在两个版本上试过,结果管理员在页面顶部看到了一条错误提示。看起来似乎不兼容?

我希望它能兼容,我真的很喜欢这种全新的玩法。

2 个赞

你遇到了什么错误?

2 个赞

你好 :waving_hand:

我添加了一个名为 open_all_topic_links 的新设置。

启用后,页面上任何指向主题的链接(帖子内容、通知、用户卡片、搜索结果、推荐/相关主题、侧边栏等)都会打开预览模态框,而不是跳转到新页面——不仅仅是主题列表中的行。指向特定帖子的链接会在打开模态框后滚动到该帖子。主题列表内部以及已打开的预览模态框内部的链接将保持原有行为。

5 个赞

你是模态框界的 GOAT :slight_smile: 有没有可能在核心功能中加入移动端右滑关闭,并支持嵌套视图 :eyes:

2 个赞