diff --git a/.discourse-compatibility b/.discourse-compatibility index bb1b784..818ba10 100644 --- a/.discourse-compatibility +++ b/.discourse-compatibility @@ -1,3 +1,4 @@ +< 3.3.0.beta1: 3179e886a366e15fb0de3c869990c2292763bd89 < 3.2.0.beta2: 0f2a0e73e6c2924f2b44d3241931f2bd5f77a9ae 3.1.999: 323bd485b08889360edcae826d6272fd8e77d180 2.7.13: 5b2f5a455e1adf8ce5e8c1cfb7fbc3c388d3d82a diff --git a/assets/sprite.svg b/assets/sprite.svg index ea133f1..2542006 100644 --- a/assets/sprite.svg +++ b/assets/sprite.svg @@ -1,5 +1,5 @@ - - + + @@ -14,5 +14,12 @@ - - + + + + + + + + + diff --git a/common/common.scss b/common/common.scss index 55e8502..0e71f5d 100644 --- a/common/common.scss +++ b/common/common.scss @@ -1,14 +1,21 @@ $padding-basis: 0.75em; -.d-toc-main { - display: none; - width: 225px; - @media screen and (max-width: 1045px) { - .desktop-view & { - width: 150px; - } +@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%; } - border-left: 1px solid var(--primary-low); +} + +.overlay .d-toc-main { + max-width: 100%; +} + +.d-toc-main { + min-width: 6em; + max-width: 13em; + + word-wrap: break-word; box-sizing: border-box; a { display: block; @@ -19,8 +26,8 @@ $padding-basis: 0.75em; } } #d-toc { + border-left: 1px solid var(--primary-low); max-height: calc(100vh - 4.5em - var(--header-offset)); - padding-bottom: 0.5em; overflow: auto; ul { list-style-type: none; @@ -44,6 +51,14 @@ $padding-basis: 0.75em; max-height: 500em; overflow: visible; opacity: 1; + animation: hide-scroll 0.3s backwards; + } + // hides the scrollbar while subsection expands + @keyframes hide-scroll { + from, + to { + overflow: hidden; + } } } > a:hover { @@ -119,67 +134,54 @@ html.rtl SELECTOR { } // END active line marker -.d-toc-mini, -a.d-toc-close { - display: none; +.d-toc-mini { + height: 100%; + button { + height: 100%; + } } -.d-toc-timeline-visible { - .d-toc-main, - .d-toc-mini { - display: block; - } - // overlayed timeline (on mobile and narrow screens) - .topic-navigation.with-topic-progress { - .d-toc-wrapper { - position: fixed; - margin-top: 0.25em; - height: calc(100vh - 50px - var(--header-offset)); - opacity: 0.5; - right: -100vw; - top: var(--header-offset); - width: 75vw; - max-width: 350px; - background-color: var(--secondary); - box-shadow: var(--shadow-dropdown); - z-index: z("modal", "overlay"); - transition: all 0.2s ease-in-out; - .d-toc-main { - width: 100%; - padding: 0.5em; - height: 100%; - #d-toc { - max-height: calc(100% - 3em); - } - } - &.overlay { - right: 0; - width: 75vw; - opacity: 1; - .d-toc-main #d-toc li.d-toc-item ul { - transition: none; - } - } - - a.scroll-to-bottom, - a.d-toc-close { - display: inline-block; - padding: 0.5em; - } - - .d-toc-icons { - text-align: right; +// overlayed timeline (on mobile and narrow screens) +.topic-navigation.with-topic-progress { + .d-toc-wrapper { + position: fixed; + margin-top: 0.25em; + height: calc(100vh - 50px - var(--header-offset)); + opacity: 0.5; + right: -100vw; + top: var(--header-offset); + width: 75vw; + max-width: 350px; + background-color: var(--secondary); + box-shadow: var(--shadow-dropdown); + z-index: z("modal", "overlay"); + transition: all 0.2s ease-in-out; + .d-toc-main { + width: 100%; + padding: 0.5em; + height: 100%; + #d-toc { + max-height: calc(100% - 3em); + } + } + &.overlay { + right: 0; + width: 75vw; + opacity: 1; + .d-toc-main #d-toc li.d-toc-item ul { + transition: none; } } - } - // core overrides when timeline is active - .timeline-container, - #topic-progress { - display: none; - } - .container.posts .topic-navigation.with-topic-progress { - align-self: start; + a.scroll-to-bottom, + a.d-toc-close { + display: inline-block; + padding: 0.5em; + } + + .d-toc-icons { + text-align: right; + } } } @@ -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; left: -100vw; &.overlay { @@ -240,3 +242,57 @@ a.d-toc-close { 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; + } +} diff --git a/javascripts/discourse/components/toc-contents.gjs b/javascripts/discourse/components/toc-contents.gjs new file mode 100644 index 0000000..74413e5 --- /dev/null +++ b/javascripts/discourse/components/toc-contents.gjs @@ -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) : []), + ]); + } + + +} diff --git a/javascripts/discourse/components/toc-heading.gjs b/javascripts/discourse/components/toc-heading.gjs new file mode 100644 index 0000000..33bceef --- /dev/null +++ b/javascripts/discourse/components/toc-heading.gjs @@ -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); + } + } + + +} diff --git a/javascripts/discourse/components/toc-large-buttons.gjs b/javascripts/discourse/components/toc-large-buttons.gjs new file mode 100644 index 0000000..9ff1711 --- /dev/null +++ b/javascripts/discourse/components/toc-large-buttons.gjs @@ -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); + } + + +} diff --git a/javascripts/discourse/components/toc-mini-buttons.gjs b/javascripts/discourse/components/toc-mini-buttons.gjs new file mode 100644 index 0000000..f405028 --- /dev/null +++ b/javascripts/discourse/components/toc-mini-buttons.gjs @@ -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); + } + + +} diff --git a/javascripts/discourse/components/toc-mini.gjs b/javascripts/discourse/components/toc-mini.gjs new file mode 100644 index 0000000..4b0ff19 --- /dev/null +++ b/javascripts/discourse/components/toc-mini.gjs @@ -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(); + } + + +} diff --git a/javascripts/discourse/components/toc-timeline.gjs b/javascripts/discourse/components/toc-timeline.gjs new file mode 100644 index 0000000..cfb82ba --- /dev/null +++ b/javascripts/discourse/components/toc-timeline.gjs @@ -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); + } + } + + +} diff --git a/javascripts/discourse/components/toc-toggle.gjs b/javascripts/discourse/components/toc-toggle.gjs new file mode 100644 index 0000000..7337d71 --- /dev/null +++ b/javascripts/discourse/components/toc-toggle.gjs @@ -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"; + } + + +} diff --git a/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.hbs b/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.hbs deleted file mode 100644 index b26df5d..0000000 --- a/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.hbs +++ /dev/null @@ -1,7 +0,0 @@ -
- -
\ No newline at end of file diff --git a/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.js b/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.js deleted file mode 100644 index 41476f0..0000000 --- a/javascripts/discourse/connectors/after-topic-progress/d-toc-mini.js +++ /dev/null @@ -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"); - } -} diff --git a/javascripts/discourse/connectors/below-docs-topic/d-toc-wrapper.hbs b/javascripts/discourse/connectors/below-docs-topic/d-toc-wrapper.hbs index dd2fa64..b9c9b59 100644 --- a/javascripts/discourse/connectors/below-docs-topic/d-toc-wrapper.hbs +++ b/javascripts/discourse/connectors/below-docs-topic/d-toc-wrapper.hbs @@ -1 +1 @@ -{{! Docs TOC placeholder }} \ No newline at end of file + \ No newline at end of file diff --git a/javascripts/discourse/connectors/topic-navigation/d-toc-wrapper.hbs b/javascripts/discourse/connectors/topic-navigation/d-toc-wrapper.hbs index cf74c43..6e4e96b 100644 --- a/javascripts/discourse/connectors/topic-navigation/d-toc-wrapper.hbs +++ b/javascripts/discourse/connectors/topic-navigation/d-toc-wrapper.hbs @@ -1 +1,5 @@ -{{! TOC placeholder }} \ No newline at end of file + \ No newline at end of file diff --git a/javascripts/discourse/initializers/disco-toc-main.js b/javascripts/discourse/initializers/disco-toc-main.js deleted file mode 100644 index f88561e..0000000 --- a/javascripts/discourse/initializers/disco-toc-main.js +++ /dev/null @@ -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 = ``; - - 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 = ``; - 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; -} diff --git a/javascripts/discourse/initializers/init-toc-mini.js b/javascripts/discourse/initializers/init-toc-mini.js new file mode 100644 index 0000000..9957225 --- /dev/null +++ b/javascripts/discourse/initializers/init-toc-mini.js @@ -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); +}); diff --git a/javascripts/discourse/initializers/init-toc-toggle.js b/javascripts/discourse/initializers/init-toc-toggle.js new file mode 100644 index 0000000..10d029d --- /dev/null +++ b/javascripts/discourse/initializers/init-toc-toggle.js @@ -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); +}); diff --git a/javascripts/discourse/services/toc-processor.js b/javascripts/discourse/services/toc-processor.js new file mode 100644 index 0000000..78dea3f --- /dev/null +++ b/javascripts/discourse/services/toc-processor.js @@ -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(`
`); + } + + 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 [" + 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" }); + } + } +} diff --git a/locales/en.yml b/locales/en.yml index 7b587ec..52db0e0 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -1,7 +1,10 @@ en: table_of_contents: 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: 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 diff --git a/spec/system/discotoc_author_spec.rb b/spec/system/discotoc_author_spec.rb new file mode 100644 index 0000000..3fb034b --- /dev/null +++ b/spec/system/discotoc_author_spec.rb @@ -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: "
\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 diff --git a/spec/system/discotoc_progress_user_spec.rb b/spec/system/discotoc_progress_user_spec.rb new file mode 100644 index 0000000..0aa63fd --- /dev/null +++ b/spec/system/discotoc_progress_user_spec.rb @@ -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: "
\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 diff --git a/spec/system/discotoc_timeline_user_spec.rb b/spec/system/discotoc_timeline_user_spec.rb new file mode 100644 index 0000000..4d5fb3d --- /dev/null +++ b/spec/system/discotoc_timeline_user_spec.rb @@ -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: "
\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 diff --git a/test/acceptance/toc-composer-test.js b/test/acceptance/toc-composer-test.js deleted file mode 100644 index 03a4b1a..0000000 --- a/test/acceptance/toc-composer-test.js +++ /dev/null @@ -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()); - }); -}); diff --git a/test/acceptance/toc-test.js b/test/acceptance/toc-test.js deleted file mode 100644 index bf34a6b..0000000 --- a/test/acceptance/toc-test.js +++ /dev/null @@ -1,177 +0,0 @@ -import { visit } from "@ember/test-helpers"; -import { test } from "qunit"; -import topicFixtures from "discourse/tests/fixtures/topic"; -import { - acceptance, - exists, - query, -} from "discourse/tests/helpers/qunit-helpers"; -import { cloneJSON } from "discourse-common/lib/object"; - -const COOKED_WITH_HEADINGS = - '

\n帖子控制

\n

\nMeasure h2

\n

Jaracaca Swamp we gazed round the very evening light in some. HTML version of science far too late. Wait a snake and nearly half-past two terrible carnivorous dinosaur and distribute. Employers Liability Act you! Each of me see that the crudest pleasantry. Sonny my own special brain. Advancing in front of them and there?

\n
\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n
questionsvanishcontention
nearerdepressedfrancisca
roomskennelgenesis
\n

\nUndeveloped h2

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.
\nCried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.
\nCried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nH1 second section

\n

\nUndeveloped 2 h2

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through.
\nYou’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSubheading 3 h3

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSubheading 3 long ass wire h3

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nAnother section h2

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSubheading again then h3

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSu-subbheading h4

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSu-subalicions heading h4

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSu-subalicions heading h4 quite long to test a real-life kind of scenario here then

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

\nSu-subalicions heading h4 also quite long to test a real-life kind of scenario here then

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n

Cried leaning upon the tangle of the full of the same. Behind us upon the luxurious. Tarp Henry of the moment that similar upon his lecture. Devil got there came well with him fifteen dollars a whisper We slunk through. You’ll find its palm. Other ones and east of Shakespeare could his seat there by. McArdle looked round and I have thrown open and his people have seen the tangled.

\n'; - -const TOC_MARKUP = '\n
'; -const TOC_AUTO_CATEGORIES = "17|19|13"; -const TOC_AUTO_TAGS = "docs|knowledge"; - -const TOC_TOPIC_TAGS = ["design", "docs"]; -const TOC_TOPIC_CATEGORY = 19; - -acceptance("DiscoTOC - main", function (needs) { - needs.pretender((server, helper) => { - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = - COOKED_WITH_HEADINGS + TOC_MARKUP; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("shows TOC, hides timeline on desktop", async function (assert) { - await visit("/t/internationalization-localization/280"); - - assert.ok(exists(".d-toc-wrapper #d-toc"), "TOC element exists"); - - const firstH2 = query(".topic-body h2"), - dTocID = firstH2.getAttribute("data-d-toc"), - matchingTocItem = `#d-toc [data-d-toc="${dTocID}"]`; - assert.equal( - firstH2.textContent.trim(), - query(matchingTocItem).textContent.trim(), - "TOC above timeline has matching items" - ); - - const bquoteH2 = query(".topic-body blockquote h2"); - assert.ok(exists(bquoteH2), "blockquote H2 exists"); - assert.equal( - bquoteH2.hasAttribute("data-d-toc"), - false, - "does not apply TOC to headings in blockquote" - ); - - assert.equal( - firstH2.hasAttribute("data-d-toc"), - true, - "does apply TOC to regular headings" - ); - - const firstH1 = query(".topic-body h1"); - - assert.equal( - firstH1.getAttribute("id"), - "toc-h1-0", - "heading gets an ID even when it has no Latin characters" - ); - }); -}); - -acceptance("DiscoTOC - off", function (needs) { - needs.pretender((server, helper) => { - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = COOKED_WITH_HEADINGS; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("no TOC markup on a regular topic", async function (assert) { - await visit("/t/internationalization-localization/280"); - assert.ok(!exists(".d-toc-wrapper #d-toc")); - }); -}); - -acceptance("DiscoTOC - with tags", function (needs) { - needs.pretender((server, helper) => { - settings.auto_TOC_tags = TOC_AUTO_TAGS; - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = COOKED_WITH_HEADINGS; - topicResponse.tags = TOC_TOPIC_TAGS; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("automaticly adds TOC based on tags", async function (assert) { - await visit("/t/internationalization-localization/280"); - assert.ok(exists(".d-toc-wrapper #d-toc")); - }); -}); - -acceptance("DiscoTOC - with categories", function (needs) { - needs.pretender((server, helper) => { - settings.auto_TOC_categories = TOC_AUTO_CATEGORIES; - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = COOKED_WITH_HEADINGS; - topicResponse.category_id = TOC_TOPIC_CATEGORY; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("automaticly adds TOC based on category", async function (assert) { - await visit("/t/internationalization-localization/280"); - assert.ok(exists(".d-toc-wrapper #d-toc")); - }); -}); - -acceptance("DiscoTOC - non-text headings", function (needs) { - needs.pretender((server, helper) => { - settings.TOC_min_heading = 1; - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = ` -

- <span style="color: red">what about this</span>

- -

test

- ${TOC_MARKUP} - `; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("renders the TOC items as plain text", async function (assert) { - await visit("/t/internationalization-localization/280"); - - const item = query(`#d-toc [data-d-toc="toc-h3-span"]`); - assert.strictEqual( - item.innerHTML.trim(), - `<span style="color: red">what about this</span>` - ); - }); -}); - -acceptance("DiscoTOC - setting TOC_min_heading", function (needs) { - needs.pretender((server, helper) => { - settings.TOC_min_heading = 3; - const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]); - topicResponse.post_stream.posts[0].cooked = - '

\n帖子控制

\nWelcome' + - TOC_MARKUP; - - server.get("/t/280.json", () => helper.response(topicResponse)); - server.get("/t/280/:post_number.json", () => - helper.response(topicResponse) - ); - }); - - test("hiding TOC element", async function (assert) { - await visit("/t/internationalization-localization/280"); - - assert.notOk( - exists(".d-toc-timeline-visible .d-toc-main"), - "TOC element not visible" - ); - }); -});