Source: scripts/utils/pageManager.js

/**
 * The class for managing the pages of the application.
 * It handles the setups and visibility of the different pages
 */

class PageManager {
    /** @type {Page} */
    current

    constructor() {
        /**
         * registered pages
         * @type {Record<string, Page>}
         */

        /**
         * @typedef {"newToolPage" | "newSettingsPage" | "newAboutPage" | "newCheatSheetPage" | "newLandingPage" | "newSelectPage"} PageName
         */

        this.pages = {
            newToolPage: new ToolsPage(),
            newNavigation: new NavigationPage(),
            newSettingsPage: new SettingsPage(),
            newAboutPage: new AboutPage(),
            newCheatSheetPage: new CheatSheetPage(),
            newLandingPage: new LandingPage(),
            newSelectPage: new SelectPage(),
            newLoadingPage: new LoadingSomethingPage(),
            newStepwisePage: new StepwisePage(),
            newKirchhoffPage: new KirchhoffPage(),
            newWheatstonePage: new WheatstonePage(),
            newMagneticPage: new MagneticPage(),
        }

        this.timeout = conf.page.values.timeout;


        awaitVal(() => state.pyodideReady, () => this.afterPyodideLoaded())
        awaitVal(
            () => Object.values(this.pages).every(obj => obj.isSetUp === true) && state.pyodideReady,
            async () => {
                await this.setupEasterEggs()
                console.log("Easter eggs setup")
            },
            1000)
    }

    /**
     * return all pages that are registered in {@link PageManager.pages}
     * @returns {Array<Page>}
     */
    get allPages(){
        return /** @type {Array<Page>} */ Object.values(pageManager.pages);
    }

    get pagesToSetup(){
        let excludes = [SelectPage, LoadingSomethingPage, NavigationPage];
        return /** @type {Array<Page>} */ this.allPages.filter(page => !excludes.some(exclude => page instanceof exclude));
    }

    /** @param newPage {Page} the page that shall be displayed
     * @param history {boolean} adding the page to the history
     */
    #showPage(newPage, history=true){
        this.hide();
        this.current = newPage;
        newPage.show();
		if (history) pageHistory.pushPage(newPage);
    }

    /** @param newPage {Page} the page that shall be displayed
     * @param force {boolean} force the page change, if page is already set
     * @param history {boolean} adding the page to the history
     */
    async changePage(newPage, force=false, history=true){


		if (newPage === this.current && !force) return;

        if (!newPage.isSetUp){
            newPage.setup();
        }
        if (!newPage.isInitialized) {
            newPage.initialize()
            this.#showPage(pageManager.pages.newLoadingPage);
            this.pages.newNavigation.close();
            this.pageReady(newPage).then(() => this.#showPage(newPage, history))
            return Promise.resolve(true);
		}

        this.#showPage(newPage, history);
        return Promise.resolve(true)
    }

    /** @param page {Page}
     * @param intervall {int}*/
    pageReady(page, intervall = 100){
        return this.ready(() => page.isSetUp && page.isInitialized, intervall);
    }

    /** @param checkFn {function}
     * @param interval {int}
     * */
    ready(checkFn, interval = 100) {
        return new Promise((resolve, reject) => {
            if (typeof checkFn !== "function") {
                reject(new Error("checkFn must be a function returning a boolean"));
                return;
            }

            const timer = setInterval(() => {
                try {
                    if (checkFn()) {
                        clearInterval(timer);
                        clearTimeout(timeOut);
                        resolve();
                    }
                } catch (err) {
                    clearInterval(timer);
                    clearTimeout(timeOut);
                    reject(err);
                }
            }, interval);

            const timeOut = setTimeout(() => {
                // on page load time out the page is not shown and therefore not the last page in the history.
                let test = pageManager.current;
                this.changePage(pageHistory.currentPage(), false, false)
                clearTimeout(timer);
                showMessage(languageManager.currentLang.alerts.pageLoadingTimeOut, "error", false)
                reject(new Error("page load timeout"));
            }, this.timeout)
        });
    }

    /** @param newPage {Page} the page that shall be displayed
     * @param force {boolean} force the page change, if page is already set
     */
    changePageAnimated(newPage, force=false){
        throw Error("not implemented :(")
    }

    /**
     * hide the currently displayed page.
     * Also see {@link PageManager.changePage}
     * @private
     */
    hide(){
        if (!this.current) return
        this.current.hide();
    }

    /**
     * slide out the page to the left
     * @param fade {int} time the page slides out
     * @param hideOffset {int} time that is waited to hide the page after fade is done
     */
    slideOut(fade = 300, hideOffset = 500){
        if (!this.current) return;
        this.current.slideOut(fade, hideOffset);
    }

    /**
     * Setup simpliPFy.org
     *  @param {Page} startPage */
    async setup(startPage = this.pages.newLandingPage){
        await storageManager.language.load()
        this.setColorScheme(); // decide on which color to set up pages
        pageHistory = new PageHistory();

        startPage.setup();
        // track how often the page was loaded on landing page
        if (startPage === this.pages.newLandingPage) storageManager.landingPageVisits.increment()
        //landing pages is initialized when shown

        this.pages.newLoadingPage.setup();
        this.pages.newLoadingPage.initialize();

        this.pages.newNavigation.setup();
        this.pages.newNavigation.initialize();

        this.changePage(startPage);

        //scroll bootstrap accordions into view when their body is displayed
        document.addEventListener('shown.bs.collapse', (Event) =>  {
            let pos = Event.target.parentElement.getBoundingClientRect();
            window.scrollTo(window.x, pos.y + window.scrollY - 60, {behavior: 'instant'});
        })
    }

    /**
     * call afterPyodideLoaded on all registered {@link Page}s in {@link PageManager.pages}
     */
    afterPyodideLoaded() {
        for (let page of Object.values(this.pages)) {
            page.afterPyodideLoaded();
        }
    }

    /**
     *
     * @param newOpacity {number} a number between 0 and 1, set opacity to this value
     * @param page {Page} the page the opacity shall be set of, can be null if all is true
     * @param all {boolean} if true opacity of all pages is set to newOpacity, page value is ignored
     */
    updatePageOpacity(newOpacity, page, all=false){
        let pages
        if (all){
            pages = Object.values(this.pages)
        }
        else{
            pages = [page]
        }

        for (let page of pages){
            page.opacity(newOpacity)
        }
    }

    /**
     * return an HTML element that is a thin line and can be used to separate content on pages
     * @param id {string} unique id for the element in the dom, is concatenated like this: "settings-divider-" + id
     * @returns {HTMLHRElement}
     */
    getPageDivider(id){
        let divider = document.createElement("hr");
        divider.classList.add("hr", "mt-5", "mb-3", "mx-auto", "pageDivider");
        divider.id = "settings-divider-" + id;
        divider.style.color = colors.currentForeground;
        divider.style.maxWidth = "600px";

        return divider;
    }

    /**
     * method used to enable the tooltips on the tools page (todo: see if this can be moved to live tracking)
     */
    enableTooltips() {
        // If DOM is loaded, execute, otherwise wait for it
        if (document.readyState === "loading") {
            document.addEventListener("DOMContentLoaded", () => {enableTt();});
        } else {
            enableTt();
        }

        function enableTt() {
            const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]')
            const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl =>
                new bootstrap.Tooltip(tooltipTriggerEl, {
                    container: "body", // append tooltip to body instead of specific divs to avoid overflow issues
                    customClass: "custom-tooltip",
                    trigger: "click"
                }));
        }
    }

    /**
     * method called if an error is produced in the code
     */
    onError() {
        let progressBar = document.getElementById('pgr-bar')
        progressBar.classList.remove('bg-warning');
        progressBar.classList.remove('progress-bar-striped');
        progressBar.classList.add('bg-danger');
        progressBar.style.width = "100%";
        languageManager.currentLang.selector.messages = ['An error occurred, please try to reload the page'];
        let pgrBarNote = document.getElementById('progress-bar-note');
        pgrBarNote.innerText = languageManager.currentLang.selector.messages[0];
        pgrBarNote.style.color = colors.currentForeground;
    }

    async setupEasterEggs() {
        await fetchEasterEggImages();
        for (let page of Object.values(this.pages)) {
            page.setupEasterEggs()
        }
    }

    /**
     * uses window.matchMedia to determine the browser language and set the page language accordingly
     */
    setColorScheme() {
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        const darkModeSwitch = document.getElementById("darkmode-switch");
        darkModeSwitch.checked = true;
        if (!prefersDark) {
            colors.setLightModeColors()
            darkModeSwitch.checked = false;
        } else {
            colors.setDarkModeColors()
        }
        updateBsClassesTo(colors.currentBsColorScheme, "bg", document.getElementById("simplifier-page-container"));
    }

    /**
     * update the color of each registered page in {@link PageManager.pages}
     */
    updateColor(){
        // update currently viewed page first
        let pages = this.allPages;
        let indexOfCurPage = pages.indexOf(this.current);
        pages.splice(indexOfCurPage, 1);

        this.current.updateColor();

        // update remaining pages
        for (let page of pages) {
            page.updateColor();
        }
    }

    /**
     * update the language of each registered page in {@link PageManager.pages}
     */
    async updateLang(){
        // update currently viewed page first
        let pages = this.allPages;
        let indexOfCurPage = pages.indexOf(this.current);
        pages.splice(indexOfCurPage, 1);

	    this.current.updateLang();
        //todo only typset updated page container
        await MathJax.typesetPromise();

        // update remaining pages
        for (let page of pages) {
            page.updateLang();
        }
        await MathJax.typesetPromise();
    }
}