mirror of
https://github.com/discourse/DiscoTOC.git
synced 2026-09-18 00:02:12 -04:00
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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
@@ -42,7 +42,9 @@ export default class TocHeading extends Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetElement = document.querySelector(`a[name="${targetId}"]`);
|
||||
const targetElement =
|
||||
document.querySelector(`a[name="${targetId}"]`) ||
|
||||
document.getElementById(targetId);
|
||||
if (targetElement) {
|
||||
const headerOffsetValue = headerOffset();
|
||||
const elementPosition =
|
||||
|
||||
@@ -39,13 +39,13 @@ export default class TocMini extends Component {
|
||||
|
||||
<template>
|
||||
{{#if this.tocProcessor.hasTOC}}
|
||||
<div class="d-toc-mini">
|
||||
<span class="d-toc-mini">
|
||||
<DButton
|
||||
class="btn-primary"
|
||||
@icon="stream"
|
||||
@action={{this.toggleTOCOverlay}}
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
{{/if}}
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -30,9 +30,15 @@ export default {
|
||||
icon: "align-left",
|
||||
label: themePrefix("insert_table_of_contents"),
|
||||
condition: (composer) => {
|
||||
return composer.model.topicFirstPost;
|
||||
return (
|
||||
settings.enable_TOC_for_replies || composer.model.topicFirstPost
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (settings.enable_TOC_for_replies) {
|
||||
document.body.classList.add("toc-for-replies-enabled");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class TocProcessor extends Service {
|
||||
}
|
||||
|
||||
shouldDisplayToc(post) {
|
||||
return post.post_number === 1;
|
||||
return settings.enable_TOC_for_replies || post.post_number === 1;
|
||||
}
|
||||
|
||||
containsTocMarkup(content) {
|
||||
@@ -133,22 +133,42 @@ export default class TocProcessor extends Service {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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];
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
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 anchor = heading.querySelector("a.anchor");
|
||||
|
||||
let id;
|
||||
if (anchor) {
|
||||
id = anchor.name;
|
||||
} else {
|
||||
id = `toc-${lowerTagName}-${slugify(text) || index}`;
|
||||
}
|
||||
const id = this.getIdFromHeading(this.postID, heading, sameIdCount);
|
||||
|
||||
// Remove irrelevant ancestors
|
||||
while (ancestors[ancestors.length - 1].level >= level) {
|
||||
@@ -172,7 +192,7 @@ export default class TocProcessor extends Service {
|
||||
}
|
||||
|
||||
jumpToEnd(renderTimeline, postID) {
|
||||
const buffer = 150;
|
||||
let buffer = 150;
|
||||
const postContainer = document.querySelector(`[data-post-id="${postID}"]`);
|
||||
|
||||
if (!renderTimeline) {
|
||||
@@ -185,6 +205,15 @@ export default class TocProcessor extends Service {
|
||||
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 -
|
||||
|
||||
Reference in New Issue
Block a user