REFACTOR: convert to ember component, add timeline toggle (#73)

This commit is contained in:
Kris
2024-01-31 10:22:06 -05:00
committed by GitHub
parent f8b8c2b765
commit 826b5fb22a
24 changed files with 1097 additions and 638 deletions
@@ -0,0 +1,161 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { inject as 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";
import TocMiniButtons from "../components/toc-mini-buttons";
const POSITION_BUFFER = 150;
const SCROLL_DEBOUNCE = 50;
const RESIZE_DEBOUNCE = 200;
export default class TocContents extends Component {
@service tocProcessor;
@tracked activeHeadingId = null;
@tracked headingPositions = [];
@tracked activeAncestorIds = [];
get flattenedToc() {
return this.flattenTocStructure(this.args.tocStructure);
}
@action
setup() {
this.listenForScroll();
this.listenForResize();
this.updateHeadingPositions();
this.updateActiveHeadingOnScroll(); // manual on setup so active class is added
}
willDestroy() {
super.willDestroy(...arguments);
window.removeEventListener("scroll", this.updateActiveHeadingOnScroll);
window.removeEventListener("resize", this.calculateHeadingPositions);
}
@action
listenForScroll() {
window.addEventListener("scroll", this.updateActiveHeadingOnScroll);
}
@action
listenForResize() {
// due to text reflow positions will change after significant resize
window.addEventListener("resize", this.calculateHeadingPositions);
}
@debounce(RESIZE_DEBOUNCE)
calculateHeadingPositions() {
this.updateHeadingPositions();
}
@action
updateHeadingPositions() {
// get the heading positions, so we know when to activate the TOC item on scroll
const postElement = document.querySelector(
`[data-post-id="${this.args.postID}"]`
);
if (!postElement) {
return;
}
const headings = postElement.querySelectorAll("h1, h2, h3, h4, h5");
if (!headings.length) {
return;
}
this.headingPositions = Array.from(headings).map((heading) => {
const id = this.getIdFromHeading(heading);
return {
id,
position:
heading.getBoundingClientRect().top +
window.scrollY -
headerOffset() -
POSITION_BUFFER,
};
});
}
@debounce(SCROLL_DEBOUNCE)
updateActiveHeadingOnScroll() {
const scrollPosition = window.pageYOffset - headerOffset();
// binary search to find the active item
let activeIndex = 0;
let low = 0;
let high = this.headingPositions.length - 1;
while (low <= high) {
let mid = Math.floor((low + high) / 2);
let heading = this.headingPositions[mid];
if (scrollPosition >= heading.position) {
low = mid + 1;
activeIndex = mid;
} else {
high = mid - 1;
}
}
const activeHeading = this.flattenedToc.find(
(h) => h.id === this.headingPositions[activeIndex]?.id
);
this.activeHeadingId = activeHeading?.id;
this.activeAncestorIds = [];
let ancestor = activeHeading;
while (ancestor && ancestor.parent) {
this.activeAncestorIds.push(ancestor.parent.id);
ancestor = ancestor.parent;
}
}
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) : []),
]);
}
<template>
{{#unless @renderTimeline}}
<TocMiniButtons @renderTimeline={{@renderTimeline}} @postID={{@postID}} />
{{/unless}}
<div id="d-toc" {{didInsert this.setup}}>
{{#each @tocStructure as |heading|}}
<ul class="d-toc-heading">
<TocHeading
@item={{heading}}
@activeHeadingId={{this.activeHeadingId}}
@activeAncestorIds={{this.activeAncestorIds}}
@renderTimeline={{@renderTimeline}}
/>
</ul>
{{/each}}
{{#if @renderTimeline}}
<TocLargeButtons
@postID={{@postID}}
@renderTimeline={{@renderTimeline}}
/>
{{/if}}
</div>
</template>
}
@@ -0,0 +1,82 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import { headerOffset } from "discourse/lib/offset-calculator";
import { slugify } from "discourse/lib/utilities";
const SCROLL_BUFFER = 25;
export default class TocHeading extends Component {
@service tocProcessor;
get isActive() {
return this.args.activeHeadingId === this.args.item.id;
}
get isAncestorActive() {
return this.args.activeAncestorIds?.includes(this.args.item.id);
}
get classNames() {
const baseClass = "d-toc-item";
const typeClass = this.args.item.tagName
? ` d-toc-${this.args.item.tagName}`
: "";
let activeClass = "";
if (this.isActive) {
activeClass = " direct-active active";
} else if (this.isAncestorActive) {
activeClass = " active";
}
return `${baseClass}${typeClass}${activeClass}`;
}
@action
handleTocLinkClick(event) {
event.preventDefault();
const targetId = event.target.href?.split("#").pop();
if (!targetId) {
return;
}
const targetElement = document.querySelector(`a[name="${targetId}"]`);
if (targetElement) {
const headerOffsetValue = headerOffset();
const elementPosition =
targetElement.getBoundingClientRect().top + window.pageYOffset;
const offsetPosition =
elementPosition - headerOffsetValue - SCROLL_BUFFER;
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
// hide TOC overlay when navigating to link
this.tocProcessor.setOverlayVisible(false);
}
}
<template>
<li class={{this.classNames}}>
<a
href="#{{@item.id}}"
{{on "click" this.handleTocLinkClick}}
data-d-toc={{concat "toc-" @item.tagName "-" (slugify @item.text)}}
>
{{@item.text}}
</a>
{{#if @item.subItems}}
<ul class="d-toc-sublevel">
{{#each @item.subItems as |subItem|}}
<TocHeading
@item={{subItem}}
@activeHeadingId={{@activeHeadingId}}
@activeAncestorIds={{@activeAncestorIds}}
/>
{{/each}}
</ul>
{{/if}}
</li>
</template>
}
@@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
export default class TocLargeButtons extends Component {
@service tocProcessor;
@action
callJumpToEnd() {
this.tocProcessor.jumpToEnd(this.args.renderTimeline, this.args.postID);
}
<template>
<div class="d-toc-footer-icons">
<DButton
@action={{this.callJumpToEnd}}
@icon="downward"
@translatedLabel={{i18n (themePrefix "jump_bottom")}}
class="btn btn-transparent scroll-to-bottom"
/>
</div>
</template>
}
@@ -0,0 +1,33 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
export default class TocMiniButtons extends Component {
@service tocProcessor;
@action
callCloseOverlay() {
this.tocProcessor.setOverlayVisible(false);
}
@action
callJumpToEnd() {
this.tocProcessor.jumpToEnd(this.args.renderTimeline, this.args.postID);
}
<template>
<div class="d-toc-icons">
<DButton
@action={{this.callJumpToEnd}}
@icon="downward"
class="btn btn-transparent scroll-to-bottom"
/>
<DButton
@action={{this.closeOverlay}}
@icon="times"
class="btn btn-transparent d-toc-close"
/>
</div>
</template>
}
@@ -0,0 +1,51 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
export default class TocMini extends Component {
@service tocProcessor;
@action
clickOutside() {
this.tocProcessor.setOverlayVisible(false);
this.removeClickOutsideListener();
}
@action
addClickOutsideListener() {
document.addEventListener("click", this.clickOutside);
}
@action
toggleTOCOverlay() {
this.tocProcessor.toggleOverlay();
if (this.tocProcessor.isOverlayVisible) {
this.addClickOutsideListener();
} else {
this.removeClickOutsideListener();
}
}
@action
removeClickOutsideListener() {
document.removeEventListener("click", this.clickOutside);
}
willDestroy() {
super.willDestroy(...arguments);
this.removeClickOutsideListener();
}
<template>
{{#if this.tocProcessor.hasTOC}}
<div class="d-toc-mini">
<DButton
class="btn-primary"
@icon="stream"
@action={{this.toggleTOCOverlay}}
/>
</div>
{{/if}}
</template>
}
@@ -0,0 +1,83 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { inject as service } from "@ember/service";
import bodyClass from "discourse/helpers/body-class";
import TocContents from "../components/toc-contents";
import TocToggle from "../components/toc-toggle";
export default class TocTimeline extends Component {
@service tocProcessor;
@tracked
isTocVisible = localStorage.getItem("tocVisibility") === "true" || true;
get shouldRenderToc() {
if (!this.tocProcessor.hasTOC) {
return false;
}
// should always show on docs routes
if (this.tocProcessor.isDocs) {
return true;
}
if (this.args.renderTimeline) {
// single post topics might not have a timeline
// so we should ignore state
if (this.args.topic?.posts_count === 1) {
return true;
}
// timeline state controlled by localStorage
return this.tocProcessor.isTocVisible;
} else {
// progress state controlled by overlay state
return this.tocProcessor.isOverlayVisible;
}
}
get isTopicProgress() {
return (
!this.args.renderTimeline ||
(this.args.renderTimeline && this.args.topicProgressExpanded)
);
}
@action
callCheckPostforTOC() {
this.tocProcessor.checkPostforTOC(this.args.topic);
}
@action
handleTimelineUpdate() {
if (this.args.renderTimeline) {
this.tocProcessor.setOverlayVisible(false);
}
}
<template>
<div
{{didInsert this.callCheckPostforTOC}}
{{didUpdate this.callCheckPostforTOC @topic.currentPost}}
{{didUpdate this.handleTimelineUpdate @renderTimeline}}
class="d-toc-main"
>
{{#if this.shouldRenderToc}}
{{#unless this.isTopicProgress}}
{{bodyClass "d-toc-active"}}
{{/unless}}
<TocContents
@postContent={{this.tocProcessor.postContent}}
@postID={{this.tocProcessor.postID}}
@tocStructure={{this.tocProcessor.tocStructure}}
@renderTimeline={{@renderTimeline}}
/>
{{#if @renderTimeline}}
<TocToggle @topic={{@topic}} />
{{/if}}
{{/if}}
</div>
</template>
}
@@ -0,0 +1,38 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import i18n from "discourse-common/helpers/i18n";
export default class TocToggle extends Component {
@service tocProcessor;
get shouldShow() {
// docs and topics with 1 post don't need a toggle
if (this.tocProcessor.isDocs || this.args.topic?.posts_count === 1) {
return false;
}
return this.tocProcessor.hasTOC;
}
get toggleLabel() {
return this.tocProcessor.isTocVisible
? "toggle_toc.show_timeline"
: "toggle_toc.show_toc";
}
get toggleIcon() {
return this.tocProcessor.isTocVisible ? "timeline" : "stream";
}
<template>
{{#if this.shouldShow}}
<DButton
@action={{this.tocProcessor.toggleTocVisibility}}
@icon={{this.toggleIcon}}
@translatedLabel={{i18n (themePrefix this.toggleLabel)}}
class="btn btn-default timeline-toggle"
/>
{{/if}}
</template>
}
@@ -1,7 +0,0 @@
<div class="d-toc-mini">
<DButton
class="btn-primary"
@action={{this.showTOCOverlay}}
@label={{theme-prefix "table_of_contents"}}
/>
</div>
@@ -1,9 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
export default class DTocMini extends Component {
@action
showTOCOverlay() {
document.querySelector(".d-toc-wrapper").classList.toggle("overlay");
}
}
@@ -1 +1 @@
{{! Docs TOC placeholder }}
<TocTimeline @topic={{@outletArgs.topic}} @renderTimeline={{true}} />
@@ -1 +1,5 @@
{{! TOC placeholder }}
<TocTimeline
@topic={{@outletArgs.topic}}
@renderTimeline={{@outletArgs.renderTimeline}}
@topicProgressExpanded={{@outletArgs.topicProgressExpanded}}
/>
@@ -1,319 +0,0 @@
import { later } from "@ember/runloop";
import { headerOffset } from "discourse/lib/offset-calculator";
import { withPluginApi } from "discourse/lib/plugin-api";
import { slugify } from "discourse/lib/utilities";
import { iconHTML } from "discourse-common/lib/icon-library";
import domUtils from "discourse-common/utils/dom-utils";
import I18n from "I18n";
export default {
name: "disco-toc-main",
initialize() {
withPluginApi("1.0.0", (api) => {
const autoTocCategoryIds = settings.auto_TOC_categories
.split("|")
.map((id) => parseInt(id, 10));
const autoTocTags = settings.auto_TOC_tags.split("|");
api.decorateCookedElement(
(el, helper) => {
if (helper) {
const post = helper.getModel();
if (post?.post_number !== 1) {
return;
}
const topicCategory = helper.getModel().topic.category_id;
const topicTags = helper.getModel().topic.tags;
const hasTOCmarkup = el?.querySelector(`[data-theme-toc="true"]`);
const tocCategory = autoTocCategoryIds?.includes(topicCategory);
const tocTag = topicTags?.some((tag) => autoTocTags?.includes(tag));
if (!hasTOCmarkup && !tocCategory && !tocTag) {
document.body.classList.remove("d-toc-timeline-visible");
return;
}
let dTocHeadingSelectors =
":scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5";
const headings = el.querySelectorAll(dTocHeadingSelectors);
if (headings.length < settings.TOC_min_heading) {
return;
}
headings.forEach((h, index) => {
// suffix uses index for non-Latin languages
const suffix = slugify(h.textContent) || index;
const id =
h.getAttribute("id") || slugify(`toc-${h.nodeName}-${suffix}`);
h.setAttribute("id", id);
h.setAttribute("data-d-toc", id);
h.classList.add("d-toc-post-heading");
});
el.classList.add("d-toc-cooked");
if (document.querySelector(".d-toc-wrapper")) {
this.insertTOC(headings);
} else {
// try again if decoration happens while outlet is not rendered
// this is due to core resetting `canRender` for topic-navigation
// when transitioning between topics
later(() => {
if (document.querySelector(".d-toc-wrapper")) {
this.insertTOC(headings);
}
}, 300);
}
}
},
{
id: "disco-toc",
onlyStream: true,
afterAdopt: true,
}
);
api.onAppEvent("topic:current-post-changed", (args) => {
if (!document.querySelector(".d-toc-cooked")) {
return;
}
if (args.post.post_number === 1) {
document.body.classList.add("d-toc-timeline-visible");
} else {
document.body.classList.remove("d-toc-timeline-visible");
}
});
api.onAppEvent("docs-topic:current-post-scrolled", () => {
this.updateTOCSidebar();
});
api.onAppEvent("topic:current-post-scrolled", (args) => {
if (args.postIndex !== 1) {
return;
}
this.updateTOCSidebar();
});
api.cleanupStream(() => {
document.body.classList.remove("d-toc-timeline-visible");
document.removeEventListener("click", this.clickTOC, false);
});
});
},
updateTOCSidebar() {
if (!document.querySelector(".d-toc-cooked")) {
return;
}
const headings = document.querySelectorAll(".d-toc-post-heading");
let closestHeadingDistance = null;
let closestHeading = null;
headings.forEach((heading) => {
const distance = Math.abs(
domUtils.offset(heading).top - headerOffset() - window.scrollY
);
if (closestHeadingDistance == null || distance < closestHeadingDistance) {
closestHeadingDistance = distance;
closestHeading = heading;
} else {
return false;
}
});
if (closestHeading) {
document.querySelectorAll("#d-toc li").forEach((listItem) => {
listItem.classList.remove("active");
listItem.classList.remove("direct-active");
});
const anchor = document.querySelector(
`#d-toc a[data-d-toc="${closestHeading.getAttribute("id")}"]`
);
if (!anchor) {
return;
}
anchor.parentElement.classList.add("direct-active");
parentsUntil(anchor, "#d-toc", ".d-toc-item").forEach((liParent) => {
liParent.classList.add("active");
});
}
},
insertTOC(headings) {
const dToc = document.createElement("div");
dToc.classList.add("d-toc-main");
dToc.innerHTML = `<div class="d-toc-icons">
<a href="#" class="scroll-to-bottom" title="${I18n.t(
themePrefix("post_bottom_tooltip")
)}">${iconHTML("downward")}</a>
<a href="#" class="d-toc-close">${iconHTML("times")}</a></div>`;
const existing = document.querySelector(".d-toc-wrapper .d-toc-main");
if (existing) {
document.querySelector(".d-toc-wrapper").replaceChild(dToc, existing);
} else {
document.querySelector(".d-toc-wrapper").appendChild(dToc);
}
const result = this.buildTOC(Array.from(headings));
document.querySelector(".d-toc-main").appendChild(result);
document.addEventListener("click", this.clickTOC, false);
},
clickTOC(e) {
const classNames = ["d-toc-timeline-visible", "archetype-docs-topic"];
if (
!classNames.some((className) =>
document.body.classList.contains(className)
)
) {
return;
}
// link to each heading
if (
e.target.closest(".d-toc-item") &&
e.target.hasAttribute("data-d-toc")
) {
const target = `#${e.target.getAttribute("data-d-toc")}`;
const scrollTo = domUtils.offset(
document.querySelector(`.d-toc-cooked ${target}`)
).top;
window.scrollTo({
top: scrollTo - headerOffset() - 10,
behavior: "smooth",
});
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
e.preventDefault();
return false;
}
if (e.target.closest("a")) {
// link to first post bottom
if (e.target.closest("a").classList.contains("scroll-to-bottom")) {
const rect = document
.querySelector(".d-toc-cooked")
.getBoundingClientRect();
if (rect) {
window.scrollTo({
top: rect.bottom + window.scrollY - headerOffset() - 10,
behavior: "smooth",
});
e.preventDefault();
return false;
}
}
// close overlay
if (e.target.closest("a").classList.contains("d-toc-close")) {
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
e.preventDefault();
return false;
}
}
if (!document.querySelector(".d-toc-wrapper.overlay")) {
return;
}
// clicking outside overlay
if (!e.target.closest(".d-toc-wrapper.overlay")) {
document.querySelector(".d-toc-wrapper").classList.remove("overlay");
}
},
buildTOC(headings) {
const result = document.createElement("div");
result.setAttribute("id", "d-toc");
const primaryH = headings[0].tagName;
const primaryHeadings = headings.filter((n) => n.tagName === primaryH);
let nextIndex = headings.length;
primaryHeadings.forEach((primaryHeading, index) => {
const ul = document.createElement("ul");
ul.classList.add("d-toc-heading");
let li = this.buildItem(primaryHeading);
ul.appendChild(li);
const currentIndex = headings.indexOf(primaryHeading);
if (primaryHeadings[index + 1]) {
nextIndex = headings.indexOf(primaryHeadings[index + 1]);
} else {
nextIndex = headings.length;
}
headings.forEach((heading, subIndex) => {
if (subIndex > currentIndex && subIndex < nextIndex) {
let subUl = li.lastChild;
if (subUl.tagName !== "UL") {
subUl = subUl.appendChild(document.createElement("ul"));
subUl.classList.add("d-toc-sublevel");
li.appendChild(subUl);
}
let subLi = this.buildItem(heading);
subUl.appendChild(subLi);
}
});
result.appendChild(ul);
});
return result;
},
buildItem(node) {
let clonedNode = node.cloneNode(true);
clonedNode.querySelector("span.clicks")?.remove();
const li = document.createElement("li");
li.classList.add("d-toc-item");
li.classList.add(`d-toc-${clonedNode.tagName.toLowerCase()}`);
const id = clonedNode.getAttribute("id");
li.innerHTML = `<a href="#" data-d-toc="${id}"></a>`;
li.querySelector("a").innerText = clonedNode.textContent.trim();
clonedNode.remove();
return li;
},
};
function parentsUntil(el, selector, filter) {
const result = [];
const matchesSelector =
el.matches ||
el.webkitMatchesSelector ||
el.mozMatchesSelector ||
el.msMatchesSelector;
// match start from parent
el = el.parentElement;
while (el && !matchesSelector.call(el, selector)) {
if (!filter) {
result.push(el);
} else {
if (matchesSelector.call(el, filter)) {
result.push(el);
}
}
el = el.parentElement;
}
return result;
}
@@ -0,0 +1,6 @@
import { apiInitializer } from "discourse/lib/api";
import TocMini from "../components/toc-mini";
export default apiInitializer("1.14.0", (api) => {
api.renderInOutlet("before-topic-progress", TocMini);
});
@@ -0,0 +1,6 @@
import { apiInitializer } from "discourse/lib/api";
import TocToggle from "../components/toc-toggle";
export default apiInitializer("1.14.0", (api) => {
api.renderInOutlet("timeline-footer-controls-after", TocToggle);
});
@@ -0,0 +1,197 @@
import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object";
import Service, { inject as service } from "@ember/service";
import { slugify } from "discourse/lib/utilities";
export default class TocProcessor extends Service {
@service router;
@tracked hasTOC = false;
@tracked postContent = null;
@tracked postID = null;
@tracked tocStructure = null;
@tracked isTocVisible = localStorage.getItem("tocVisibility") !== "false";
@tracked isOverlayVisible = false;
@tracked isDocs = false;
@action
toggleTocVisibility() {
this.isTocVisible = !this.isTocVisible;
localStorage.setItem("tocVisibility", this.isTocVisible);
}
setOverlayVisible(visible) {
this.isOverlayVisible = visible;
const tocWrapper = document.querySelector(".d-toc-wrapper");
if (tocWrapper) {
tocWrapper.classList.toggle("overlay", visible);
}
}
toggleOverlay() {
this.setOverlayVisible(!this.isOverlayVisible);
}
checkPostforTOC(topic) {
this.hasTOC = false;
if (
this.isValidTopic(topic) &&
this.shouldDisplayToc(this.getCurrentPost(topic))
) {
const content = this.getCurrentPost(topic).cooked;
if (this.containsTocMarkup(content) || this.autoTOC(topic)) {
this.processPostContent(content, this.getCurrentPost(topic).id);
}
}
this.setOverlayVisible(false);
}
isValidTopic(topic) {
return !!topic;
}
getCurrentPost(topic) {
const docs = this.router?.currentRouteName?.includes("docs");
if (docs) {
this.isDocs = true;
return topic.post_stream.posts[0];
}
this.isDocs = false;
return topic.postStream?.posts?.find(
(post) => post.post_number === topic.currentPost
);
}
shouldDisplayToc(post) {
return post.post_number === 1;
}
containsTocMarkup(content) {
return content.includes(`<div data-theme-toc="true">`);
}
processPostContent(content, postId) {
// no headings, no parsing
if (this.containsHeadings(content)) {
const parsedPost = new DOMParser().parseFromString(content, "text/html");
// direct descendants to avoid picking up headings in quotes
const headings = parsedPost.querySelectorAll(
"body > h1,body > h2,body > h3,body > h4,body > h5"
);
if (headings.length < settings.TOC_min_heading) {
this.setOverlayVisible(false);
return;
}
this.populateTocData(postId, content, headings);
} else {
this.setOverlayVisible(false);
}
}
containsHeadings(content) {
return ["<h1", "<h2", "<h3", "<h4", "<h5"].some((tag) =>
content.includes(tag)
);
}
populateTocData(postId, content, headings) {
this.hasTOC = true;
this.postID = postId;
this.postContent = content;
this.tocStructure = this.generateTocStructure(headings);
}
autoTOC(topic) {
// check topic for categories or tags from settings
const autoCategories = settings.auto_TOC_categories
? settings.auto_TOC_categories.split("|").map((id) => parseInt(id, 10))
: [];
const autoTags = settings.auto_TOC_tags
? settings.auto_TOC_tags.split("|")
: [];
if ((!autoCategories.length && !autoTags.length) || !topic) {
return false;
}
const topicCategory = topic.category_id;
const topicTags = topic.tags || [];
const hasMatchingTags = autoTags.some((tag) => topicTags.includes(tag));
const hasMatchingCategory = autoCategories.includes(topicCategory);
// only apply autoTOC on first post
// the docs plugin only shows the first post, and does not have topic.currentPost defined
return (
(hasMatchingTags || hasMatchingCategory) &&
(topic.currentPost === 1 || topic.currentPost === undefined)
);
}
generateTocStructure(headings) {
let root = { subItems: [], level: 0 };
let ancestors = [root];
headings.forEach((heading, index) => {
const level = parseInt(heading.tagName[1], 10);
const text = heading.textContent.trim();
const lowerTagName = heading.tagName.toLowerCase();
const anchor = heading.querySelector("a.anchor");
let id;
if (anchor) {
id = anchor.name;
} else {
id = `toc-${lowerTagName}-${slugify(text) || index}`;
}
// Remove irrelevant ancestors
while (ancestors[ancestors.length - 1].level >= level) {
ancestors.pop();
}
let headingData = {
id,
tagName: lowerTagName,
text,
subItems: [],
level,
parent: ancestors.length > 1 ? ancestors[ancestors.length - 1] : null,
};
ancestors[ancestors.length - 1].subItems.push(headingData);
ancestors.push(headingData);
});
return root.subItems;
}
jumpToEnd(renderTimeline, postID) {
const buffer = 150;
const postContainer = document.querySelector(`[data-post-id="${postID}"]`);
if (!renderTimeline) {
this.setOverlayVisible(false);
}
if (postContainer) {
// if the topic map is present, we don't want to scroll past it
// so the post controls are still visible
const topicMapHeight =
postContainer.querySelector(`.topic-map`)?.offsetHeight || 0;
const offsetPosition =
postContainer.getBoundingClientRect().bottom +
window.scrollY -
buffer -
topicMapHeight;
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
}
}
}