/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { instanceSymbol } from "../../constants.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { CustomElement } from "../../dom/customelement.mjs"; import { assembleMethodSymbol, registerCustomElement, } from "../../dom/customelement.mjs"; import { TableOfContentStyleSheet } from "./stylesheet/table-of-content.mjs"; import { fireCustomEvent } from "../../dom/events.mjs"; import { getWindow } from "../../dom/util.mjs"; import "../layout/popper.mjs"; export { TableOfContent }; /** * @private * @type {symbol} */ const tableOfContentElementSymbol = Symbol("tableOfContentElement"); /** * @private * @type {symbol} */ const navigationElementSymbol = Symbol("navigation"); /** * @private * @type {symbol} */ const navigationControlElementSymbol = Symbol("navigationControlElement"); /** * @private * @type {symbol} */ const navigationListElementSymbol = Symbol("navigationListElement"); /** * @private * @type {symbol} */ const windowEventHandlerSymbol = Symbol("windowsEventHandler"); /** * @private * @type {symbol} */ const scrollableParentSymbol = Symbol("scrollableParent"); /** * @private * @type {symbol} */ const scrollableEventHandlerSymbol = Symbol("scrollableEventHandler"); /** * A TableOfContent * * @fragments /fragments/components/form/table-of-content/ * * @example /examples/components/form/table-of-content-simple * * @since 3.65.0 * @copyright schukai GmbH * @summary A beautiful TableOfContent that can make your life easier and also looks good. * @fires new-top The new top position */ class TableOfContent extends CustomElement { /** * This method is called by the `instanceof` operator. * @returns {symbol} */ static get [instanceSymbol]() { return Symbol.for( "@schukai/monster/components/navigation/table-of-content@@instance", ); } /** * * @return {Components.Navigation.TableOfContent */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); return this; } /** * To set the options via the html tag the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} labels Label definitions * @property {Object} features Features * @property {boolean} features.showScrollToTop=true Show scroll to top * @property {boolean} features.showScrollToBottom=true Show scroll to bottom * @property {number} offset=100 Navigation offset from top * @property {string} position="right" Navigation position (right, left) * @property {Object} classes CSS classes * @property {boolean} disabled=false Disabled state */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, labels: { scrollToTop: "⇧", scrollToBottom: "⇩", }, classes: {}, disabled: false, features: { showScrollToTop: true, showScrollToBottom: true, }, offset: 50, position: "right", }); } /** * @return {void} */ connectedCallback() { super.connectedCallback(); initNavigation.call(this); const position = this.getOption("position"); if (position === "left") { this[navigationElementSymbol].classList.remove("right"); this[navigationElementSymbol].classList.add("left"); } else { this[navigationElementSymbol].classList.remove("left"); this[navigationElementSymbol].classList.add("right"); } setTimeout(() => { this[scrollableParentSymbol] = findScrollableParent(this); if (this[scrollableParentSymbol] === getWindow()) { if(["absolute", "relative", "fixed", "sticky"].indexOf(this[scrollableParentSymbol].style.position) === -1) { this.style.position = "relative"; } this[scrollableParentSymbol].addEventListener( "scroll", this[windowEventHandlerSymbol], ); calcAndSetNavigationTopWindowContext.call(this); } else { if(["absolute", "relative", "fixed", "sticky"].indexOf(this[scrollableParentSymbol].style.position) === -1) { this[scrollableParentSymbol].style.position = "relative"; } this[scrollableParentSymbol].addEventListener( "scroll", this[scrollableEventHandlerSymbol], ); calcAndSetNavigationTopScrollableParentContext.call(this); } }, 0); } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); if (!this[scrollableParentSymbol]) { return; } if (this[scrollableParentSymbol] === getWindow()) { this[scrollableParentSymbol].removeEventListener( "scroll", this[windowEventHandlerSymbol], ); } else { this[scrollableParentSymbol].removeEventListener( "scroll", this[scrollableEventHandlerSymbol], ); } } /** * @return {string} */ static getTag() { return "monster-table-of-content"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [TableOfContentStyleSheet]; } } /** * @private * @return {void} * @fires new-top - The new top position */ function calcAndSetNavigationTopWindowContext() { const rect = this.getBoundingClientRect(); const thisTop = rect.top; const thisBottom = rect.bottom; let top = 0; if (thisTop < 0) { top = + (-1*thisTop); } const offset = this.getOption("offset"); if (offset > 0) { top += offset; } if (thisBottom<0) { return; } fireCustomEvent(this, "new-top", { top: top }); this[navigationElementSymbol].style.top = top + "px"; } /** * @private * @return {void} * @fires new-top - The new top position */ function calcAndSetNavigationTopScrollableParentContext() { if (!this[scrollableParentSymbol]) { return; } const scrollTop = this[scrollableParentSymbol].scrollTop; const thisTop = scrollTop; let top = 0; top+=scrollTop; console.log(thisTop,scrollTop) const offset = this.getOption("offset"); if (offset > 0) { top += offset; } fireCustomEvent(this, "new-top", { top: top }); this[navigationElementSymbol].style.top = top + "px"; } /** * @private */ function initNavigation() { const headings = getHeadings.call(this); for (const heading of headings) { const div = document.createElement("div"); div.classList.add("heading-strip"); div.classList.add("level-" + heading.tagName.toLowerCase()); this[navigationControlElementSymbol].appendChild(div); } let startLevel = 7; for (const heading of headings) { if (parseInt(heading.tagName.substring(1)) < startLevel) { startLevel = parseInt(heading.tagName.substring(1)); } } if (startLevel === 7) { // no headings found return; } this[navigationListElementSymbol].appendChild( createListFromHeadings.call(this, headings, startLevel).sublist, ); const footer = document.createElement("div"); footer.classList.add("footer"); if (this.getOption("features.showScrollToTop")) { const scrollToTop = document.createElement("div"); scrollToTop.textContent = this.getOption("labels.scrollToTop"); scrollToTop.classList.add("scroll-to-top"); scrollToTop.addEventListener("click", () => { if (!this[scrollableParentSymbol]) { return; } this[scrollableParentSymbol].scrollTo(0, 0); }); footer.appendChild(scrollToTop); } if (this.getOption("features.showScrollToBottom")) { const scrollToBottom = document.createElement("div"); scrollToBottom.textContent = this.getOption("labels.scrollToBottom"); scrollToBottom.classList.add("scroll-to-bottom"); scrollToBottom.addEventListener("click", () => { if (!this[scrollableParentSymbol]) { return; } this[scrollableParentSymbol].scrollTo( 0, this[scrollableParentSymbol].scrollHeight, ); }); footer.appendChild(scrollToBottom); } if (footer.children.length > 0) { this[navigationListElementSymbol].appendChild(footer); } } /** * Recursively creates a nested list (UL) from a list of heading elements. * @param {HTMLElement[]} nodeList - The list of heading elements. * @param {number} currentLevel - The current heading level we are processing. * @returns {{sublist: HTMLUListElement, lastIndex: number}} An object containing the sublist and the index of the last processed element. */ function createListFromHeadings(nodeList, currentLevel = 1) { const self = this; let ul = document.createElement("ul"); let i = 0; while (i < nodeList.length) { const node = nodeList[i]; const level = parseInt(node.tagName.substring(1)); if (level === currentLevel) { const li = document.createElement("li"); li.textContent = node.textContent; li.addEventListener("click", (e) => { e.stopPropagation(); getWindow().requestAnimationFrame(() => { window.scrollTo(0, 0); // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView // mostly supported node?.scrollIntoView({ behavior: "smooth" }); }); }); ul.appendChild(li); i++; } else if (level > currentLevel) { if (ul.lastChild) { const { sublist, lastIndex } = createListFromHeadings.call( self, nodeList.slice(i), level, ); ul.lastChild.appendChild(sublist); i += lastIndex; } else { throw new Error( "Heading structure error: higher level " + level + " found without a parent (level " + currentLevel + ")", ); } } else { break; } } return { sublist: ul, lastIndex: i }; } /** * @private * @returns {*[]} */ function getHeadings() { const allHeadings = []; const slots = this.shadowRoot.querySelectorAll("slot"); slots.forEach((slot) => { const slottedElements = slot.assignedElements(); slottedElements.forEach((element) => { if (element instanceof HTMLHeadingElement) { allHeadings.push(element); return; } const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6"); let nodeList = Array.from(headings); // remove all with attribute data-monster-table-of-content-omit nodeList = nodeList.filter((node) => { return !node.hasAttribute("data-monster-table-of-content-omit"); }); allHeadings.push(...nodeList); }); }); return allHeadings; } /** * @private * @return {initEventHandler} */ function initEventHandler() { const self = this; let ticking = false; this[windowEventHandlerSymbol] = function () { if (!ticking) { getWindow().requestAnimationFrame(() => { calcAndSetNavigationTopWindowContext.call(self); ticking = false; }); ticking = true; } }; this[scrollableEventHandlerSymbol] = function () { if (!ticking) { getWindow().requestAnimationFrame(() => { calcAndSetNavigationTopScrollableParentContext.call(self); ticking = false; }); ticking = true; } }; const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { getWindow().requestAnimationFrame(() => { if (!this[scrollableParentSymbol]) { return; } if (self[scrollableParentSymbol] === getWindow()) { calcAndSetNavigationTopWindowContext.call(self); } else { calcAndSetNavigationTopScrollableParentContext.call(self); } ticking = false; }); ticking = true; } }); }, { root: null, rootMargin: "0px", threshold: 0.1, }, ); observer.observe(this); return this; } /** * * @param {HTMLElement} element * @return {HTMLElement|Window} */ function findScrollableParent(element) { let parent = element.parentElement; while (parent) { const overflowY = getWindow().getComputedStyle(parent).overflowY; if (overflowY === "scroll" || overflowY === "auto") { return parent; } parent = parent.parentElement; } return getWindow(); } /** * @private * @return {void} */ function initControlReferences() { this[tableOfContentElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="control"]`, ); this[navigationElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="navigation"]`, ); this[navigationControlElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="navigation-control"]`, ); this[navigationListElementSymbol] = this.shadowRoot.querySelector( `[${ATTRIBUTE_ROLE}="navigation-list"]`, ); } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div data-monster-role="control" part="control"> <div class="navigation" data-monster-role="navigation"> <monster-popper data-monster-option-mode="enter"> <div slot="button" data-monster-role="navigation-control"> </div> <div data-monster-role="navigation-list"> </div> </monster-popper> </div> <slot></slot> </div>`; } registerCustomElement(TableOfContent);