主题列表中的标签显示组件 - 展开/折叠主题列表中的标签

注意:在将此组件发布到主题组件之前,我想先征求一些反馈,看看这个主题组件是否合格,或者是否存在任何重大问题。

:warning: 披露:此主题组件是在 AI 编码工具的帮助下规划、实现和测试的。

非常希望听到您的反馈!


:information_source: 摘要 标签显示
:eyeglasses: 预览 暂无…
:hammer_and_wrench: 代码库 GitHub - jrgong420/discourse-tag-reveal
:question: 安装指南 如何安装主题或主题组件
:open_book: Discourse 主题新手? Discourse 主题使用入门指南

Discourse Tag Reveal 是一个轻量级的主题组件,它通过仅显示每个主题的前 N 个标签并将其余标签替换为可访问的“+X 个更多标签”切换按钮来保持主题列表整洁。用户可以展开以查看所有标签,然后折叠回缩短的视图。它开箱即用,支持 Discourse 的标准标签 UI,无需服务器端更改。

功能

  • 可通过主题设置配置标签限制(默认:5)

  • 样式为标签的切换按钮,可通过键盘访问(Enter/Space),并带有 ARIA 属性

  • 使用 themePrefix 和 discourse-i18n 进行本地化字符串处理

  • SPA 安全行为:在页面更改时重置并重新应用逻辑

  • 通过 MutationObserver 支持无限滚动

  • 最小化 CSS;尊重核心标签样式

  • 无模板覆盖或插件依赖

截图/演示

…即将推出

安装与配置

  • 测试的 Discourse 版本:3.6.0beta1

  • 在组件的“设置”选项卡下配置设置:

  • max_tags_visible(整数,默认 5):在折叠之前显示的标签数量

  • toggle_tag_style:切换按钮的视觉样式,以匹配标签外观(目前仅实现了“box”样式)

  • 范围:影响主题列表(最新、新、未读和分类主题列表)

与其他主题组件的兼容性

:warning: 仅进行了最少的测试,请在部署到生产环境之前自行测试

注意事项

  • 确保已启用标签功能(管理 → 设置 → 标签),否则您将看不到任何效果

  • 如果您的站点大量自定义了标签 CSS,您可能需要调整 .ts-toggle 样式以实现完美的视觉对齐

未来想法

我并不打算实现更多功能,但很乐意接受 PR。一些未来的想法:

  • 在主题视图中启用/禁用标签

  • 对特定页面和/或分类进行精细控制

2 个赞

您是故意将此设置命名为与核心中的设置相同吗?我担心会产生误解。

1 个赞

抓得好!刚修改了……

2 个赞

看起来很有趣。我稍后会在我的开发环境中尝试,因为它似乎在 Theme Creator 中不起作用(除非我做错了什么?):思考:

听起来很有趣!您能分享一些该功能实际运行的截图或屏幕录像吗?

:smiley: 在首帖添加了一个快速视频演示,请看这里:

我甚至还没有检查如何提交/添加我的 TC 组件……:smiley:
但无论如何,我首先想在这里收集一些反馈,一旦它准备好在 Theme component 中发布,我就会看看如何添加它。

3 个赞

主题创建者不使用框样式

您可能想使用

more_tags:
  one: "+%{count} 个更多标签"
  other: "+%{count} 个更多标签"
1 个赞

说得有理。我忘了将默认标签更改为 +%{count} more 以保持简洁,我们就是这样使用它来保持内容紧凑和整洁的。

1 个赞

嘿,

在某些情况下,此功能可能很有趣!

乍一看,有几点需要注意:

  • 主题设置和站点设置不相同。您需要先检索服务才能访问 max_tags_per_topic,例如:const siteSettings = api.container.lookup(\"service:site-settings\");

  • 获取限制的额外检查可能不是必需的;您可以直接检索该值。您或许可以执行 Math.min(settings.max_tags_visible, siteSettings.max_tags_per_topic )

  • 您没有恢复分隔符的可见性。

  • 您可能需要注销事件

  • 使用 MutationObserver 时,初始加载时的过程不应是必需的。通常,在全局化之前,您会想先检查是否可以通过 API(例如插件插座)来缩小元素范围。

我来检查一下是否有其他方法!

1 个赞

既然它在 api-initializers 文件中,那么 @service siteSettings 也能起作用吗?

现在可以检查了吗?最新的提交应该已经修复了之前指出的问题。

Discourse 的最低版本为 3.6.0,这意味着需要很长时间才能有人能够使用它。您是指 3.5.0 还是 3.6.0beta1?

我指的是 3.6.0beta1,那是我一直在测试的版本……

您在类中使用此。否则将不起作用。

那么您想写 3.6.0.beta1,否则目前没有人可以安装它。

我检查了一下。确实,没有直接的方法可以实现这一点;但是,我找到了一种使用 API 实现它的有趣且简化的方法。

  • 它使用主题模型来更改在模板生成之前将输出哪些可见标签。这意味着没有 DOM 操作,并且与设置无关。根据状态(revealTags),它将返回原始列表或部分列表。

  • 要创建切换按钮,它使用 API 添加一个带有按钮 HTML 的标签(不幸的是,这里没有插件出口)。单击事件是单独处理的。单击时,将更新切换状态(revealTags),并触发标签列表的重新渲染。

这种方式最大的优点是您不必弄乱 HTML 并根据不同的样式找出要显示/取消隐藏的内容。

chrome_lSKqwYt5Z7

在此分享我的测试代码:

import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { computed } from "@ember/object";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  let topicModels = {};

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels[this.id] = this;
        }

        @computed("tags")
        get visibleListTags() {
          if (this.revealTags) {
            return super.visibleListTags;
          }
          return super.visibleListTags.slice(0, maxVisibleTags);
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic, params) => {
      if (topic.tags.length <= maxVisibleTags) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: topic.tags.length - maxVisibleTags,
          });

      return `<a class="reveal-tag-action" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  document.addEventListener("click", (event) => {
    const target = event.target;
    if (!target?.matches(".reveal-tag-action")) {
      return;
    }

    event.preventDefault();
    event.stopPropagation();

    const element =
      target.closest("[data-topic-id]") ||
      document.querySelector("h1[data-topic-id]");
    const topicId = element?.dataset.topicId;
    if (!topicId) {
      return;
    }

    const topicModel = topicModels[topicId];
    if (!topicModel) {
      return;
    }

    topicModel.revealTags = !topicModel.revealTags;
    topicModel.notifyPropertyChange("tags");
  });
});
.reveal-tag-action {
  background-color: var(--primary-50);
  border: 1px solid var(--primary-200);
  color: var(--primary-800);
  font-size: small;
  padding-inline: 3px;
}

.discourse-tags__tag-separator:has(+ .reveal-tag-action) {
  visibility: hidden;
}

2 个赞

各位,我又推送了一个更新,并添加了额外的实验性功能(“精选”标签,它们始终排在最前面,并且不计入最大数量 + 主题列表视图中的“突出显示主题”行),因此总体 TC 正在进行一些调整,增加了更多扩展功能,以根据配置的选项卡突出显示某些内容。

@Arkshine 感谢您分享简化的方法,我非常感激!!!

它还影响了单个主题视图,因此我们添加了一个设置来手动启用该行为。此外,使用新方法,在导航到不同路由/页面时,展开状态会保持不变,但我尚未解决这个问题。

我已将其实现到此分支:

  • 我认为你需要审查一下CSS。

    • 你可能不应该给切换按钮添加 discourse-tag,它不是一个标签。
    • 也不要在上面使用 box 类,它会弄乱列表样式。
    • toggle_tag_style 设置只有“box”值,也许你可以添加“none”,这样它就能更好地适应列表/项目符号样式。
    • 从简单的开始,你可以根据需要进行调整。
      .reveal-tag-action {
        color: var(--primary-500);
      
        &.-box {
          background-color: var(--primary-50);
          outline: 1px solid var(--primary-200);
          padding-inline: 8px;
        }
      }
      
      /* 隐藏切换按钮前最后一个分隔符 */
      .discourse-tags__tag-separator:has(+ .reveal-tag-action) {
        visibility: hidden;
      }
      

    Bs1rdLFyIU

    站点和主题设置中的框样式:
    chrome_raXs2Gc1sd

    我将对你注入的CSS提供更多反馈。现在该睡觉了。

1 个赞

实际上是故意的。例如,主题投票插件在“x 票”元素上使用该类。

→ 在 Feature - Discourse Meta 类别中查看实际效果

谢谢,我会检查!

目前我只实现了 box 样式,因为那是我在我自己的 discourse 实例上使用的。我稍后会添加缺失的样式(欢迎提交 PR ;))

我明白了。我认为如果你将信息显示为标签,这是有道理的,但在这里,它是一个显示更多标签的按钮;对我来说,上下文是不同的。这取决于你;我认为这无关紧要。

继续反馈:

  • 标签列表可以在其他地方显示,例如:类别页面、用户活动等。我可能会删除 collapse_in_topic_view 设置,并创建一个新的特定路由设置,或者只是在所有地方启用它。
    在我的测试代码中,我使用了类似这样的方法来忽略其他路由:
JS
    function isAllowedRoute(routeName) {
        const fullRoutesName = [
          "index",
          "userActivity.topics",
          "userActivity.read",
          ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
        ];

        const partialRoutesName = ["topic."];

        if (
          fullRoutesName.includes(routeName) ||
          partialRoutesName.some((partial) => routeName.startsWith(partial))
        ) {
          return true;
        }

        return false;
      }
  • CSS 注入可以通过 API 替换,将类添加到 topic-list-item 和标签,然后将 CSS 移动到 common.css

例如:

JS
```js
import { defaultRenderTag } from "discourse/lib/render-tag";

api.registerValueTransformer(
  "topic-list-item-class",
  ({ value, context }) => {
    if (highlightedTagsSet.size === 0) {
      return value;
    }

    if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
      return [...value, `highlighted-tag__${settings.highlighted_style}`];
    }

    return value;
  }
);

api.replaceTagRenderer((tag, params) => {
  if (highlightedTagsSet.has(tag)) {
    params.extraClass = params.extraClass || "";
    params.extraClass += "highlighted";
  }

  return defaultRenderTag(tag, params);
});
```
CSS
/* Hides the last separator before the toggle button */
.discourse-tags__tag-separator:has(+ .reveal-tag-action) {
  visibility: hidden;
}

.reveal-tag-action {
  color: var(--primary-500);

  &.-box {
    background-color: var(--primary-50);
    outline: 1px solid var(--primary-200);
    padding-inline: 8px;
  }
}

.latest-topic-list-item,
.topic-list-item {
  .discourse-tag.highlighted {
    color: var(--tertiary);
    border-color: var(--tertiary);
    background: color-mix(in srgb, var(--tertiary) 12%, transparent);
    font-weight: 600;
  }

  &.highlighted-tag {
    &__left-border {
      border-left: 3px solid var(--tertiary);
      background: color-mix(in srgb, var(--tertiary) 6%, transparent);
      transition: box-shadow 160ms ease;

      &:hover {
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
      }
    }

    &__outline {
      outline: 1px solid var(--tertiary);
      outline-offset: -2px;
      border-radius: 7px;
      background: color-mix(in srgb, var(--tertiary) 5%, transparent);
      box-shadow: 0 1px 6px rgba(0, 0, 0, 0.06);
      transition: background-color 160ms ease;
    }

    &__card {
      border-left: 3px solid var(--tertiary);
      background: var(--tertiary-very-low);
      border-radius: var(--border-radius);
      padding-block: var(--space-2);
      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
      transition: box-shadow 160ms ease;

      &:hover {
        box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1);
      }
    }
  }
}

  ```
  • 你不需要从 onPageChange 设置当前路由,你可以从路由器访问它。
  • 小心标签的大小写。你的站点设置不强制小写,所以我认为最好不要修改标签。
  • 关于重置状态,你可能可以使用 onPageChange
JS
```js
api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
```
  • 如果可能的话,添加测试将是很好的。

这是完整的测试代码(我做了一些小的更改)

JS
import { computed } from "@ember/object";
import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import { defaultRenderTag } from "discourse/lib/render-tag";
import { service } from "@ember/service";

export default apiInitializer((api) => {
  const siteSettings = api.container.lookup("service:site-settings");
  const router = api.container.lookup("service:router");

  const maxVisibleTags = Math.min(
    settings.max_tags_visible,
    siteSettings.max_tags_per_topic
  );

  const highlightedTagsSet = new Set(settings.highlighted_tags.split("|"));
  const topicModels = new Map();

  function isAllowedRoute(routeName) {
    const fullRoutesName = [
      "index",
      "userActivity.topics",
      "userActivity.read",
      "tag.show",
      ...siteSettings.top_menu.split("|").map((item) => `discovery.${item}`),
    ];

    const partialRoutesName = ["topic."];

    if (
      fullRoutesName.includes(routeName) ||
      partialRoutesName.some((partial) => routeName.startsWith(partial))
    ) {
      return true;
    }

    return false;
  }

  api.modifyClass(
    "model:topic",
    (Superclass) =>
      class extends Superclass {
        @service router;

        revealTags = false;

        init() {
          super.init(...arguments);
          topicModels.set(String(this.id), this);
        }

        willDestroy() {
          super.willDestroy(...arguments);
          topicModels.delete(String(this.id));
        }

        @computed("tags")
        get visibleListTags() {
          const baseTags = super.visibleListTags || [];

          if (!isAllowedRoute(this.router.currentRouteName)) {
            return baseTags;
          }

          const highlightedList = [];
          const regularList = [];

          baseTags.forEach((tag) => {
            if (highlightedTagsSet.has(tag)) {
              highlightedList.push(tag);
            } else {
              regularList.push(tag);
            }
          });

          if (this.revealTags) {
            return [...highlightedList, ...regularList];
          }

          return [...highlightedList, ...regularList.slice(0, maxVisibleTags)];
        }
      }
  );

  api.addTagsHtmlCallback(
    (topic) => {
      if (!isAllowedRoute(topic.router.currentRouteName)) {
        return "";
      }

      const allTags = topic.tags || [];
      if (allTags.length === 0) {
        return "";
      }

      const highlightedCount = allTags.filter((tag) =>
        highlightedTagsSet.has(tag)
      ).length;
      const regularCount = allTags.length - highlightedCount;
      const effectiveLimit =
        highlightedCount + Math.min(regularCount, maxVisibleTags);

      // Only show toggle if there are hidden tags
      if (allTags.length <= effectiveLimit) {
        return "";
      }

      const isExpanded = topic.revealTags;
      const hiddenCount = allTags.length - effectiveLimit;
      const label = isExpanded
        ? i18n(themePrefix("js.tag_reveal.hide"))
        : i18n(themePrefix("js.tag_reveal.more_tags"), {
            count: hiddenCount,
          });

      const classList = ["discourse-tag", "reveal-tag-action"];
      if (settings.toggle_tag_style === "box") {
        classList.push("-box");
      }

      return `<a class="${classList.join(" ")}" role="button" aria-expanded="${isExpanded}">${label}</a>`;
    },
    {
      priority: siteSettings.max_tags_per_topic + 1,
    }
  );

  api.registerValueTransformer(
    "topic-list-item-class",
    ({ value, context }) => {
      if (highlightedTagsSet.size === 0) {
        return value;
      }

      if (context.topic?.tags?.some((tag) => highlightedTagsSet.has(tag))) {
        return [...value, `highlighted-tag__${settings.highlighted_style}`];
      }

      return value;
    }
  );

  api.replaceTagRenderer((tag, params) => {
    let newParams = params;

    if (highlightedTagsSet.has(tag)) {
      newParams = {
        ...params,
        extraClass: [params.extraClass, "highlighted"]
          .filter(Boolean)
          .join(" "),
      };
    }

    return defaultRenderTag(tag, newParams);
  });

  document.addEventListener(
    "click",
    (event) => {
      const target = event.target;
      if (!target?.matches(".reveal-tag-action")) {
        return;
      }

      event.preventDefault();
      event.stopPropagation();

      const element =
        target.closest("[data-topic-id]") ||
        document.querySelector("h1[data-topic-id]");
      const topicId = element?.dataset.topicId;
      if (!topicId) {
        return;
      }

      const topicModel = topicModels.get(topicId);
      if (!topicModel) {
        return;
      }

      topicModel.revealTags = !topicModel.revealTags;
      topicModel.notifyPropertyChange("tags");
    },
    true
  );

  api.onPageChange((url) => {
    const route = api.container.lookup("service:router").recognize(url);
    if (!isAllowedRoute(route?.name)) {
      return;
    }

    for (const [id, model] of topicModels) {
      if (model && model.revealTags) {
        model.revealTags = false;
        model.notifyPropertyChange("tags");
      }
    }
  });
});