mirror of
https://github.com/discourse/DiscoTOC.git
synced 2026-09-19 00:21:36 -04:00
This commit adds a button at the top of the TOC headings list called "Expand all". This will expand all of the subheadings in the list for easier searching. Clicking the button again will hide all the subheadings except the currently active one.
202 lines
5.3 KiB
Plaintext
202 lines
5.3 KiB
Plaintext
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 { service } from "@ember/service";
|
|
import DButton from "discourse/components/d-button";
|
|
import { headerOffset } from "discourse/lib/offset-calculator";
|
|
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;
|
|
@service appEvents;
|
|
|
|
@tracked activeHeadingId = null;
|
|
@tracked headingPositions = [];
|
|
@tracked activeAncestorIds = [];
|
|
@tracked expandAll = false;
|
|
|
|
willDestroy() {
|
|
super.willDestroy(...arguments);
|
|
window.removeEventListener("scroll", this.updateActiveHeadingOnScroll);
|
|
window.removeEventListener("resize", this.calculateHeadingPositions);
|
|
this.appEvents.off(
|
|
"topic:current-post-changed",
|
|
this.calculateHeadingPositions
|
|
);
|
|
}
|
|
|
|
get mappedToc() {
|
|
return this.mappedTocStructure(this.args.tocStructure);
|
|
}
|
|
|
|
get expandLabel() {
|
|
return this.expandAll ? "Reset expand" : "Expand all";
|
|
}
|
|
|
|
@action
|
|
setup() {
|
|
this.listenForScroll();
|
|
this.listenForPostChange();
|
|
this.listenForResize();
|
|
this.updateHeadingPositions();
|
|
this.updateActiveHeadingOnScroll(); // manual on setup so active class is added
|
|
}
|
|
|
|
@action
|
|
listenForScroll() {
|
|
window.addEventListener("scroll", this.updateActiveHeadingOnScroll);
|
|
}
|
|
|
|
@action
|
|
listenForResize() {
|
|
// due to text reflow positions will change after significant resize
|
|
window.addEventListener("resize", this.calculateHeadingPositions);
|
|
}
|
|
|
|
@action
|
|
listenForPostChange() {
|
|
this.appEvents.on(
|
|
"topic:current-post-changed",
|
|
this.calculateHeadingPositions
|
|
);
|
|
}
|
|
|
|
@action
|
|
toggleExpandAll() {
|
|
this.expandAll = !this.expandAll;
|
|
}
|
|
|
|
@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;
|
|
}
|
|
|
|
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)
|
|
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.mappedToc[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;
|
|
}
|
|
}
|
|
|
|
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>
|
|
{{#unless @renderTimeline}}
|
|
<TocMiniButtons @renderTimeline={{@renderTimeline}} @postID={{@postID}} />
|
|
{{/unless}}
|
|
<div
|
|
id="d-toc"
|
|
{{didInsert this.setup}}
|
|
{{didUpdate this.updateHeadingPositions @postID}}
|
|
>
|
|
<div class="d-toc-top-buttons">
|
|
<DButton
|
|
@action={{this.toggleExpandAll}}
|
|
@translatedLabel={{this.expandLabel}}
|
|
/>
|
|
</div>
|
|
|
|
{{#each @tocStructure as |heading|}}
|
|
<ul class="d-toc-heading">
|
|
<TocHeading
|
|
@item={{heading}}
|
|
@activeHeadingId={{this.activeHeadingId}}
|
|
@activeAncestorIds={{this.activeAncestorIds}}
|
|
@renderTimeline={{@renderTimeline}}
|
|
@expandAll={{this.expandAll}}
|
|
/>
|
|
</ul>
|
|
{{/each}}
|
|
|
|
{{#if @renderTimeline}}
|
|
<TocLargeButtons
|
|
@postID={{@postID}}
|
|
@renderTimeline={{@renderTimeline}}
|
|
/>
|
|
{{/if}}
|
|
</div>
|
|
</template>
|
|
}
|