mirror of
https://github.com/discourse/DiscoTOC.git
synced 2026-09-16 23:42:13 -04:00
REFACTOR: convert to ember component, add timeline toggle (#73)
This commit is contained in:
@@ -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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user