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}}
/>
File diff suppressed because it is too large Load Diff
@@ -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" });
}
}
}