REFACTOR: convert to ember component, add timeline toggle (#73)
This commit is contained in:
parent
f8b8c2b765
commit
826b5fb22a
|
@ -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
|
||||||
|
|
|
@ -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 |
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
Loading…
Reference in New Issue