Skip to content
Snippets Groups Projects
Select Git revision
  • ba4ce5b99c3136e97f35a8e65adccc286db7244b
  • master default protected
  • 1.31
  • 4.24.3
  • 4.24.2
  • 4.24.1
  • 4.24.0
  • 4.23.6
  • 4.23.5
  • 4.23.4
  • 4.23.3
  • 4.23.2
  • 4.23.1
  • 4.23.0
  • 4.22.3
  • 4.22.2
  • 4.22.1
  • 4.22.0
  • 4.21.0
  • 4.20.1
  • 4.20.0
  • 4.19.0
  • 4.18.0
23 results

websocket.mjs

Blame
  • table-of-content.mjs 13.64 KiB
    /**
     * 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.
    	 * @return {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
     * @param element
     * @returns {number|number|*|number}
     */
    function getScrollHeight(element) {
    	
    	if (element instanceof ShadowRoot) {
    		return element.host.scrollHeight;
    	}
    	
    	if (element === getWindow()) {
    		return element.document.documentElement.scrollHeight;
    	}
    	
    	return element.scrollHeight;
    	
    }
    
    /**
     * @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;
    
    	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,
    				getScrollHeight(this[scrollableParentSymbol]),
    			);
    		});
    		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.
     * @return {{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
     * @return {*[]}
     */
    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);
    
    			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);