mirror of
https://github.com/discourse/DiscoTOC.git
synced 2026-09-16 15:32:13 -04:00
* 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.
227 lines
6.2 KiB
JavaScript
227 lines
6.2 KiB
JavaScript
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 settings.enable_TOC_for_replies || 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)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {number} postId
|
|
* @param {HTMLHeadingElement} heading
|
|
* @param {Map<string, number>} sameIdCount
|
|
*/
|
|
getIdFromHeading(postId, heading, sameIdCount) {
|
|
const anchor = heading.querySelector("a.anchor");
|
|
if (anchor) {
|
|
return anchor.name;
|
|
}
|
|
const lowerTagName = heading.tagName.toLowerCase();
|
|
const text = heading.textContent.trim();
|
|
let slug = `${slugify(text)}`;
|
|
if (sameIdCount.has(slug)) {
|
|
sameIdCount.set(slug, sameIdCount.get(slug) + 1);
|
|
slug = `${slug}-${sameIdCount.get(slug)}`;
|
|
} else {
|
|
sameIdCount.set(slug, 1);
|
|
}
|
|
const res = `p-${postId}-toc-${lowerTagName}-${slug}`;
|
|
heading.id = res;
|
|
return res;
|
|
}
|
|
|
|
generateTocStructure(headings) {
|
|
let root = { subItems: [], level: 0 };
|
|
let ancestors = [root];
|
|
|
|
const sameIdCount = new Map();
|
|
|
|
headings.forEach((heading) => {
|
|
const level = parseInt(heading.tagName[1], 10);
|
|
const text = heading.textContent.trim();
|
|
const lowerTagName = heading.tagName.toLowerCase();
|
|
|
|
const id = this.getIdFromHeading(this.postID, heading, sameIdCount);
|
|
|
|
// 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) {
|
|
let 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;
|
|
|
|
if (
|
|
postContainer.parentElement?.nextElementSibling?.querySelector(
|
|
"div[data-theme-toc]"
|
|
)
|
|
) {
|
|
// but if the next post also has a toc, just jump to it
|
|
buffer = 30 - topicMapHeight;
|
|
}
|
|
|
|
const offsetPosition =
|
|
postContainer.getBoundingClientRect().bottom +
|
|
window.scrollY -
|
|
buffer -
|
|
topicMapHeight;
|
|
|
|
window.scrollTo({ top: offsetPosition, behavior: "smooth" });
|
|
}
|
|
}
|
|
}
|