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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1098 additions and 639 deletions

View File

@ -1,3 +1,4 @@
< 3.3.0.beta1: 3179e886a366e15fb0de3c869990c2292763bd89
< 3.2.0.beta2: 0f2a0e73e6c2924f2b44d3241931f2bd5f77a9ae < 3.2.0.beta2: 0f2a0e73e6c2924f2b44d3241931f2bd5f77a9ae
3.1.999: 323bd485b08889360edcae826d6272fd8e77d180 3.1.999: 323bd485b08889360edcae826d6272fd8e77d180
2.7.13: 5b2f5a455e1adf8ce5e8c1cfb7fbc3c388d3d82a 2.7.13: 5b2f5a455e1adf8ce5e8c1cfb7fbc3c388d3d82a

View File

@ -15,4 +15,11 @@
c18.825,0,34.133-15.309,34.133-34.133S419.883,443.733,401.067,443.733z"/> c18.825,0,34.133-15.309,34.133-34.133S419.883,443.733,401.067,443.733z"/>
</g> </g>
</g> </g>
</symbol></svg> </symbol>
<symbol id="timeline" viewBox="0 0 3.24 10.5">
<path d="M0 4.26v1.98c0 .74.5 1.34 1.12 1.34h1c.62 0 1.12-.6 1.12-1.34V4.26c0-.74-.5-1.34-1.12-1.34h-1C.5 2.92 0 3.52 0 4.26Z" class="cls-1"/>
<rect width="1.08" height="10.5" x="1.08" class="cls-1" rx=".38" ry=".38"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 850 B

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,14 +1,21 @@
$padding-basis: 0.75em; $padding-basis: 0.75em;
@media screen and (min-width: 925px) {
.container.posts {
// needs to be static, otherwise we get content shifts when the TOC shows/hides
grid-template-columns: 75% 25%;
}
}
.overlay .d-toc-main {
max-width: 100%;
}
.d-toc-main { .d-toc-main {
display: none; min-width: 6em;
width: 225px; max-width: 13em;
@media screen and (max-width: 1045px) {
.desktop-view & { word-wrap: break-word;
width: 150px;
}
}
border-left: 1px solid var(--primary-low);
box-sizing: border-box; box-sizing: border-box;
a { a {
display: block; display: block;
@ -19,8 +26,8 @@ $padding-basis: 0.75em;
} }
} }
#d-toc { #d-toc {
border-left: 1px solid var(--primary-low);
max-height: calc(100vh - 4.5em - var(--header-offset)); max-height: calc(100vh - 4.5em - var(--header-offset));
padding-bottom: 0.5em;
overflow: auto; overflow: auto;
ul { ul {
list-style-type: none; list-style-type: none;
@ -44,6 +51,14 @@ $padding-basis: 0.75em;
max-height: 500em; max-height: 500em;
overflow: visible; overflow: visible;
opacity: 1; opacity: 1;
animation: hide-scroll 0.3s backwards;
}
// hides the scrollbar while subsection expands
@keyframes hide-scroll {
from,
to {
overflow: hidden;
}
} }
} }
> a:hover { > a:hover {
@ -119,18 +134,15 @@ html.rtl SELECTOR {
} }
// END active line marker // END active line marker
.d-toc-mini, .d-toc-mini {
a.d-toc-close { height: 100%;
display: none; button {
height: 100%;
}
} }
.d-toc-timeline-visible { // overlayed timeline (on mobile and narrow screens)
.d-toc-main, .topic-navigation.with-topic-progress {
.d-toc-mini {
display: block;
}
// overlayed timeline (on mobile and narrow screens)
.topic-navigation.with-topic-progress {
.d-toc-wrapper { .d-toc-wrapper {
position: fixed; position: fixed;
margin-top: 0.25em; margin-top: 0.25em;
@ -171,16 +183,6 @@ a.d-toc-close {
text-align: right; text-align: right;
} }
} }
}
// core overrides when timeline is active
.timeline-container,
#topic-progress {
display: none;
}
.container.posts .topic-navigation.with-topic-progress {
align-self: start;
}
} }
// core sets first child's top margin to 0 // core sets first child's top margin to 0
@ -202,7 +204,7 @@ a.d-toc-close {
} }
} }
.d-toc-timeline-visible .topic-navigation.with-topic-progress .d-toc-wrapper { .topic-navigation.with-topic-progress .d-toc-wrapper {
right: unset; right: unset;
left: -100vw; left: -100vw;
&.overlay { &.overlay {
@ -240,3 +242,57 @@ a.d-toc-close {
display: block; display: block;
} }
} }
// toggle in timeline
.timeline-container
.topic-timeline
.timeline-footer-controls
button:last-child {
// annoying core style
&.timeline-toggle {
margin-right: 100%;
white-space: nowrap;
}
}
// toggle in timeline dtoc
.d-toc-main {
.timeline-toggle {
margin-top: 1em;
}
}
// jump to bottom in timeline
.d-toc-footer-icons {
font-size: var(--font-down-1);
margin-top: 0.5em;
button {
color: var(--tertiary);
.d-icon {
color: currentColor;
}
}
}
// on shorter screens, we can keep this consistently in the same location
// this is kind of far away for tall screens, so the more variable position below might be better
@media screen and (max-height: 950px) {
.timeline-toggle {
position: fixed;
bottom: 0;
}
}
// hides the timeline when d-toc is shown
.d-toc-active {
.timeline-container {
display: none;
}
}
// hide the toggle in the expanded timeline on mobile
.timeline-fullscreen {
.timeline-toggle {
display: none;
}
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -1,7 +0,0 @@
<div class="d-toc-mini">
<DButton
class="btn-primary"
@action={{this.showTOCOverlay}}
@label={{theme-prefix "table_of_contents"}}
/>
</div>

View File

@ -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");
}
}

View File

@ -1 +1 @@
{{! Docs TOC placeholder }} <TocTimeline @topic={{@outletArgs.topic}} @renderTimeline={{true}} />

View File

@ -1 +1,5 @@
{{! TOC placeholder }} <TocTimeline
@topic={{@outletArgs.topic}}
@renderTimeline={{@outletArgs.renderTimeline}}
@topicProgressExpanded={{@outletArgs.topicProgressExpanded}}
/>

View File

@ -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;
}

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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" });
}
}
}

View File

@ -1,7 +1,10 @@
en: en:
table_of_contents: table of contents table_of_contents: table of contents
insert_table_of_contents: Insert table of contents insert_table_of_contents: Insert table of contents
post_bottom_tooltip: Navigate to post controls jump_bottom: Jump to end
toggle_toc:
show_timeline: Timeline
show_toc: Contents
theme_metadata: theme_metadata:
settings: settings:
minimum_trust_level_to_create_TOC: The minimum trust level a user must have in order to see the TOC button in the composer minimum_trust_level_to_create_TOC: The minimum trust level a user must have in order to see the TOC button in the composer

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
RSpec.describe "DiscoTOC", system: true do
let!(:theme) { upload_theme_component }
fab!(:category)
fab!(:user) { Fabricate(:user, trust_level: TrustLevel[1], refresh_auto_groups: true) }
fab!(:topic_1) { Fabricate(:topic) }
fab!(:post_1) {
Fabricate(:post, raw: "<div data-theme-toc='true'></div>\n\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_1)
}
before do
sign_in(user)
end
it "composer has table of contents button" do
visit("/c/#{category.id}")
find("#create-topic").click
find(".toolbar-popup-menu-options").click
expect(page).to have_css("[data-name='Insert table of contents']")
end
it "table of contents button inserts markup into composer" do
visit("/c/#{category.id}")
find("#create-topic").click
find(".toolbar-popup-menu-options").click
find("[data-name='Insert table of contents']").click
expect(page).to have_css(".d-editor-preview [data-theme-toc='true']")
end
it "table of contents button is hidden by trust level setting" do
theme.update_setting(:minimum_trust_level_to_create_TOC, "2" )
theme.save!
visit("/c/#{category.id}")
find("#create-topic").click
find(".toolbar-popup-menu-options").click
expect(page).to have_no_css("[data-name='Insert table of contents']")
end
it "table of contents button does not appear on replies" do
visit("/t/#{topic_1.id}")
find(".reply").click
find(".toolbar-popup-menu-options").click
expect(page).to have_no_css("[data-name='Insert table of contents']")
end
end

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
RSpec.describe "DiscoTOC", system: true do
let!(:theme) { upload_theme_component }
fab!(:category)
fab!(:tag)
fab!(:topic_1) { Fabricate(:topic) }
fab!(:topic_2) { Fabricate(:topic, category: category, tags: [tag]) }
fab!(:post_1) {
Fabricate(:post, raw: "<div data-theme-toc='true'></div>\n\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_1)
}
fab!(:post_2) {
Fabricate(:post, raw: "\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_2)
}
fab!(:post_3) {
Fabricate(:post, raw: "intentionally \n long \n content \n so \n there's \n plenty \n to be \n scrolled \n past \n which \n will \n force \n the \n timeline \n to \n hide \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll ", topic: topic_1)
}
it "table of contents button appears in mobile view" do
visit("/t/#{topic_1.id}/?mobile_view=1")
expect(page).to have_css(".d-toc-mini")
end
it "clicking the toggle button toggles the timeline" do
visit("/t/#{topic_1.id}/?mobile_view=1")
find(".d-toc-mini").click
expect(page).to have_css(".d-toc-wrapper.overlay")
end
it "timeline toggle does not appear when the progress bar timeline is expanded" do
visit("/t/#{topic_1.id}/?mobile_view=1")
find("#topic-progress").click
expect(page).to have_no_css(".timeline-toggle")
end
it "d-toc-mini is hidden when scrolled past the first post" do
visit("/t/#{topic_1.id}/?mobile_view=1")
page.execute_script <<~JS
window.scrollTo(0, document.body.scrollHeight);
JS
expect(page).to have_css(".d-toc-mini")
end
it "d-toc-mini does not appear if the first post does not contain the markup" do
visit("/t/#{topic_2.id}/?mobile_view=1")
expect(page).to have_no_css(".d-toc-mini")
end
it "d-toc-mini will appear without markup if auto_TOC_categories is set to the topic's category" do
theme.update_setting(:auto_TOC_categories, "#{category.id}" )
theme.save!
visit("/t/#{topic_2.id}/?mobile_view=1")
expect(page).to have_css(".d-toc-mini")
end
it "d-toc-mini will not appear automatically if auto_TOC_categories is set to a different category" do
theme.update_setting(:auto_TOC_categories, "99" )
theme.save!
visit("/t/#{topic_2.id}/?mobile_view=1")
expect(page).to have_no_css(".d-toc-mini")
end
it "d-toc-mini will appear without markup if auto_TOC_tags is set to the topic's tag" do
theme.update_setting(:auto_TOC_tags, "#{tag.name}" )
theme.save!
visit("/t/#{topic_2.id}/?mobile_view=1")
expect(page).to have_css(".d-toc-mini")
end
it "d-toc-mini will not appear automatically if auto_TOC_tags is set to a different tag" do
theme.update_setting(:auto_TOC_tags, "wrong-tag" )
theme.save!
visit("/t/#{topic_2.id}/?mobile_view=1")
expect(page).to have_no_css(".d-toc-mini")
end
it "d-toc-mini does not appear if it has fewer headings than TOC_min_heading setting" do
theme.update_setting(:TOC_min_heading, 5)
theme.save!
visit("/t/#{topic_1.id}/?mobile_view=1")
expect(page).to have_no_css(".d-toc-mini")
end
end

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
RSpec.describe "DiscoTOC", system: true do
let!(:theme) { upload_theme_component }
fab!(:category)
fab!(:tag)
fab!(:topic_1) { Fabricate(:topic) }
fab!(:topic_2) { Fabricate(:topic, category: category, tags: [tag]) }
fab!(:post_1) {
Fabricate(:post, raw: "<div data-theme-toc='true'></div>\n\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_1)
}
fab!(:post_2) {
Fabricate(:post, raw: "\n# Heading 1\nContent for the first heading\n## Heading 2\nContent for the second heading\n### Heading 3\nContent for the third heading\n# Heading 4\nContent for the fourth heading", topic: topic_2)
}
fab!(:post_3) {
Fabricate(:post, raw: "intentionally \n long \n content \n so \n there's \n plenty \n to be \n scrolled \n past \n which \n will \n force \n the \n timeline \n to \n hide \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll \n scroll ", topic: topic_1)
}
it "table of contents appears when the relevant markup is added to first post in topic" do
visit("/t/#{topic_1.id}")
expect(page).to have_css(".d-toc-item.d-toc-h1")
end
it "clicking the toggle button toggles the timeline" do
visit("/t/#{topic_1.id}")
find(".timeline-toggle").click
expect(page).to have_css(".timeline-scrollarea-wrapper")
find(".timeline-toggle").click
expect(page).to have_css(".d-toc-item.d-toc-h1")
end
it "timeline does not appear when the table of contents is shown" do
visit("/t/#{topic_1.id}")
expect(page).to have_no_css(".topic-timeline")
end
it "table of contents is hidden when scrolled past the first post" do
visit("/t/#{topic_1.id}")
page.execute_script <<~JS
window.scrollTo(0, document.body.scrollHeight);
JS
expect(page).to have_css(".topic-timeline")
end
it "table of contents does not appear if the first post does not contain the markup" do
visit("/t/#{topic_2.id}")
expect(page).to have_no_css(".d-toc-item.d-toc-h1")
end
it "timeline will appear without markup if auto_TOC_categories is set to the topic's category" do
theme.update_setting(:auto_TOC_categories, "#{category.id}" )
theme.save!
visit("/t/#{topic_2.id}")
expect(page).to have_css(".d-toc-item.d-toc-h1")
end
it "timeline will not appear automatically if auto_TOC_categories is set to a different category" do
theme.update_setting(:auto_TOC_categories, "99" )
theme.save!
visit("/t/#{topic_2.id}")
expect(page).to have_no_css(".d-toc-item.d-toc-h1")
end
it "timeline will appear without markup if auto_TOC_tags is set to the topic's tag" do
theme.update_setting(:auto_TOC_tags, "#{tag.name}" )
theme.save!
visit("/t/#{topic_2.id}")
expect(page).to have_css(".d-toc-item.d-toc-h1")
end
it "timeline will not appear automatically if auto_TOC_tags is set to a different tag" do
theme.update_setting(:auto_TOC_tags, "wrong-tag" )
theme.save!
visit("/t/#{topic_2.id}")
expect(page).to have_no_css(".d-toc-item.d-toc-h1")
end
it "timeline does not appear if it has fewer headings than TOC_min_heading setting" do
theme.update_setting(:TOC_min_heading, 5)
theme.save!
visit("/t/#{topic_1.id}")
expect(page).to have_no_css(".d-toc-item.d-toc-h1")
end
end

View File

@ -1,54 +0,0 @@
import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import {
acceptance,
exists,
query,
} from "discourse/tests/helpers/qunit-helpers";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import I18n from "discourse-i18n";
acceptance("DiscoTOC - Composer", function (needs) {
needs.user();
needs.settings({
general_category_id: 1,
default_composer_category: 1,
});
test("Can use TOC when creating a topic", async function (assert) {
await visit("/");
await click("#create-topic");
const toolbarPopupMenu = selectKit(".toolbar-popup-menu-options");
await toolbarPopupMenu.expand();
await toolbarPopupMenu.selectRowByName(
I18n.t(themePrefix("insert_table_of_contents"))
);
assert.ok(query(".d-editor-input").value.includes('data-theme-toc="true"'));
});
test("Can use TOC when editing first post", async function (assert) {
await visit("/t/internationalization-localization/280");
await click("#post_1 .show-more-actions");
await click("#post_1 .edit");
assert.ok(exists("#reply-control"));
const toolbarPopupMenu = selectKit(".toolbar-popup-menu-options");
await toolbarPopupMenu.expand();
await toolbarPopupMenu.selectRowByName(
I18n.t(themePrefix("insert_table_of_contents"))
);
assert.ok(query(".d-editor-input").value.includes('data-theme-toc="true"'));
});
test("no TOC option when replying", async function (assert) {
await visit("/t/internationalization-localization/280");
await click(".create.reply");
const toolbarPopupMenu = selectKit(".toolbar-popup-menu-options");
await toolbarPopupMenu.expand();
assert.notOk(toolbarPopupMenu.rowByValue("insertDtoc").exists());
});
});

File diff suppressed because one or more lines are too long