filter/mathjaxloader/amd/src/loader.js

// This file is part of Moodle - http://moodle.org/ //
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/**
 * Mathjax JS Loader.
 *
 * @module filter_mathjaxloader/loader
 * @copyright 2014 Damyon Wiese  <damyon@moodle.com>
 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */
import {
    eventTypes,
    notifyFilterContentRenderingComplete,
} from 'core_filters/events';

/**
 * URL to MathJax.
 * @type {string|null}
 */
let mathJaxUrl = null;

/**
 * Promise that is resolved when MathJax was loaded.
 * @type {Promise|null}
 */
let mathJaxLoaded = null;

/**
 * Called by the filter when it is active on any page.
 * This does not load MathJAX yet - it adds the configuration in case it gets loaded later.
 * It also subscribes to the filter-content-updated event so MathJax can respond to content loaded by Ajax.
 *
 * @param {Object} params List of configuration params containing mathjaxurl, mathjaxconfig (text) and lang
 */
export const configure = (params) => {
    let config = {};
    try {
        if (params.mathjaxconfig !== '') {
            config = JSON.parse(params.mathjaxconfig);
        }
    }
    catch (e) {
        window.console.error('Invalid JSON in mathjaxconfig.', e);
    }
    if (typeof config != 'object') {
        config = {};
    }
    if (typeof config.loader !== 'object') {
        config.loader = {};
    }
    if (!Array.isArray(config.loader.load)) {
        config.loader.load = [];
    }
    if (typeof config.startup !== 'object') {
        config.startup = {};
    }

    // Always ensure that ui/safe is in the list. Otherwise, there is a risk of XSS.
    // https://docs.mathjax.org/en/v3.2-latest/options/safe.html.
    if (!config.loader.load.includes('ui/safe')) {
        config.loader.load.push('ui/safe');
    }

    // This filter controls what elements to typeset.
    config.startup.typeset = false;

    // Let's still set the locale even if the localization is not yet ported to version 3.2.2
    // https://docs.mathjax.org/en/v3.2-latest/upgrading/v2.html#not-yet-ported-to-version-3.
    config.locale = params.lang;

    mathJaxUrl = params.mathjaxurl;
    window.MathJax = config;

    // Listen for events triggered when new text is added to a page that needs
    // processing by a filter.
    document.addEventListener(eventTypes.filterContentUpdated, contentUpdated);
};

/**
 * Add the node to the typeset queue.
 *
 * @param {HTMLElement} node The Node to be processed by MathJax
 * @private
 */
const typesetNode = (node) => {
    if (!(node instanceof HTMLElement)) {
        // We may have been passed a #text node.
        // These cannot be formatted.
        return;
    }

    loadMathJax().then(() => {
        // Chain the calls to typesetPromise as it is recommended.
        // https://docs.mathjax.org/en/v3.2-latest/web/typeset.html#handling-asynchronous-typesetting.
        window.MathJax.startup.promise = window.MathJax.startup.promise
            .then(() => window.MathJax.typesetPromise([node]))
            .then(() => {
                notifyFilterContentRenderingComplete([node]);
            })
            .catch(e => {
                window.console.log(e);
            });
    });
};

/**
 * Called by the filter when an equation is found while rendering the page.
 */
export const typeset = () => {
    const elements = document.getElementsByClassName('filter_mathjaxloader_equation');
    for (const element of elements) {
        typesetNode(element);
    }
};

/**
 * Handle content updated events - typeset the new content.
 *
 * @param {CustomEvent} event - Custom event with "nodes" indicating the root of the updated nodes.
 */
export const contentUpdated = (event) => {
    let listOfElementContainMathJax = [];
    let hasMathJax = false;
    // The list of HTMLElements in an Array.
    event.detail.nodes.forEach((node) => {
        if (!(node instanceof HTMLElement)) {
            // We may have been passed a #text node.
            return;
        }
        const mathjaxElements = node.querySelectorAll('.filter_mathjaxloader_equation');
        if (mathjaxElements.length > 0) {
            hasMathJax = true;
        }
        listOfElementContainMathJax.push(mathjaxElements);
    });

    if (!hasMathJax) {
        return;
    }

    listOfElementContainMathJax.forEach((mathjaxElements) => {
        mathjaxElements.forEach((node) => typesetNode(node));
    });
};

/**
 * Load the MathJax script.
 *
 * @return Promise that is resolved when MathJax was loaded.
 */
export const loadMathJax = () => {
    if (!mathJaxLoaded) {
        if (!mathJaxUrl) {
            return Promise.reject(new Error('URL to MathJax not set.'));
        }

        mathJaxLoaded = new Promise((resolve, reject) => {
            const script = document.createElement('script');
            script.type = 'text/javascript';
            script.onload = resolve;
            script.onerror = reject;
            script.src = mathJaxUrl;
            document.getElementsByTagName('head')[0].appendChild(script);
        });
    }
    return mathJaxLoaded;
};