From 830c0436c859c7721f53d5059909ee075696eba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=94=A6=E5=BF=83?= <41134017+Lhcfl@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:40:11 +0800 Subject: [PATCH] FEATURE: Allow TOC for replies (#90) * FEATURE: Allow TOC for replies This commit adds an optional setting that allows enabling a TOC for replies. TOCs for replies are not affected by autoTOC settings like `auto_TOC_tags` and must be inserted manually. --- common/common.scss | 3 +- .../discourse/components/toc-contents.gjs | 79 ++++++++----- .../discourse/components/toc-heading.gjs | 4 +- javascripts/discourse/components/toc-mini.gjs | 4 +- .../initializers/disco-toc-composer.js | 8 +- .../discourse/services/toc-processor.js | 49 ++++++-- locales/en.yml | 1 + settings.yml | 2 + spec/system/discotoc_author_spec.rb | 34 ++++-- spec/system/discotoc_progress_user_spec.rb | 111 ++++++++++++++---- spec/system/discotoc_timeline_user_spec.rb | 96 +++++++++++---- 11 files changed, 296 insertions(+), 95 deletions(-) diff --git a/common/common.scss b/common/common.scss index 0e71f5d..d9777fc 100644 --- a/common/common.scss +++ b/common/common.scss @@ -215,7 +215,8 @@ html.rtl SELECTOR { } // Composer preview notice -.edit-title .d-editor-preview [data-theme-toc] { +.edit-title .d-editor-preview [data-theme-toc], +body.toc-for-replies-enabled .d-editor-preview [data-theme-toc] { background: var(--tertiary); color: var(--secondary); position: sticky; diff --git a/javascripts/discourse/components/toc-contents.gjs b/javascripts/discourse/components/toc-contents.gjs index eb16ae3..163ec67 100644 --- a/javascripts/discourse/components/toc-contents.gjs +++ b/javascripts/discourse/components/toc-contents.gjs @@ -4,7 +4,6 @@ import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import { service } from "@ember/service"; import { headerOffset } from "discourse/lib/offset-calculator"; -import { slugify } from "discourse/lib/utilities"; import { debounce } from "discourse-common/utils/decorators"; import TocHeading from "../components/toc-heading"; import TocLargeButtons from "../components/toc-large-buttons"; @@ -16,18 +15,20 @@ const RESIZE_DEBOUNCE = 200; export default class TocContents extends Component { @service tocProcessor; + @service appEvents; @tracked activeHeadingId = null; @tracked headingPositions = []; @tracked activeAncestorIds = []; - get flattenedToc() { - return this.flattenTocStructure(this.args.tocStructure); + get mappedToc() { + return this.mappedTocStructure(this.args.tocStructure); } @action setup() { this.listenForScroll(); + this.listenForPostChange(); this.listenForResize(); this.updateHeadingPositions(); this.updateActiveHeadingOnScroll(); // manual on setup so active class is added @@ -37,6 +38,10 @@ export default class TocContents extends Component { super.willDestroy(...arguments); window.removeEventListener("scroll", this.updateActiveHeadingOnScroll); window.removeEventListener("resize", this.calculateHeadingPositions); + this.appEvents.off( + "topic:current-post-changed", + this.calculateHeadingPositions + ); } @action @@ -50,6 +55,14 @@ export default class TocContents extends Component { window.addEventListener("resize", this.calculateHeadingPositions); } + @action + listenForPostChange() { + this.appEvents.on( + "topic:current-post-changed", + this.calculateHeadingPositions + ); + } + @debounce(RESIZE_DEBOUNCE) calculateHeadingPositions() { this.updateHeadingPositions(); @@ -71,17 +84,27 @@ export default class TocContents extends Component { return; } - this.headingPositions = Array.from(headings).map((heading) => { - const id = this.getIdFromHeading(heading); - return { - id, - position: - heading.getBoundingClientRect().top + - window.scrollY - - headerOffset() - - POSITION_BUFFER, - }; - }); + const sameIdCount = new Map(); + const mappedToc = this.mappedToc; + this.headingPositions = Array.from(headings) + .map((heading) => { + const id = this.tocProcessor.getIdFromHeading( + this.args.postID, + heading, + sameIdCount + ); + return mappedToc[id] + ? { + id, + position: + heading.getBoundingClientRect().top + + window.scrollY - + headerOffset() - + POSITION_BUFFER, + } + : null; + }) + .compact(); } @debounce(SCROLL_DEBOUNCE) @@ -104,9 +127,8 @@ export default class TocContents extends Component { } } - const activeHeading = this.flattenedToc.find( - (h) => h.id === this.headingPositions[activeIndex]?.id - ); + const activeHeading = + this.mappedToc[this.headingPositions[activeIndex]?.id]; this.activeHeadingId = activeHeading?.id; this.activeAncestorIds = []; @@ -117,20 +139,15 @@ export default class TocContents extends Component { } } - getIdFromHeading(heading) { - // reuse content from autolinked headings - const tagName = heading.tagName.toLowerCase(); - const text = heading.textContent.trim(); - const anchor = heading.querySelector("a.anchor"); - return anchor ? anchor.name : `toc-${tagName}-${slugify(text)}`; - } - - flattenTocStructure(tocStructure) { - // the post content is flat, but we want to keep the relationships added in tocStructure - return tocStructure.flatMap((item) => [ - item, - ...(item.subItems ? this.flattenTocStructure(item.subItems) : []), - ]); + mappedTocStructure(tocStructure, map = null) { + map ??= {}; + for (const item of tocStructure) { + map[item.id] = item; + if (item.subItems) { + this.mappedTocStructure(item.subItems, map); + } + } + return map; }