Repository

js/Mobilizing/core/util/Loader.js

import EventEmitter from './EventEmitter';
import * as debug from './Debug';
import Ajax from '../../net/Ajax';

/**
* @TODO virer tout ce qui est relatif à Three.js ici, ça n'a pas à être là, mais plutôt dans renderer/3D/three
*/

/**
* Fired by a LoadRequest when it has started loading
* @event start
*/
const EVT_REQUEST_START = 'start';

/**
* Fired by a LoadRequest when it has successfully finished loading
* @event load
* @param {Mixed} value The loaded and processed value
*/
const EVT_REQUEST_LOAD = 'load';

/**
* Fired by a LoadRequest when it has failed loading
* @event error
* @param {String} error The error
*/
const EVT_REQUEST_ERROR = 'error';

/**
* Fired when one of the Loader's requests has finished loading
* @event load
* @param {LoadRequest} request The LoadRequest
*/
const EVT_LOAD = 'load';

/**
* Fired when one of the Loader's requests has failed loading
* @event error
* @param {LoadRequest} request The LoadRequest
*/
const EVT_ERROR = 'error';

/**
* Fired when all the Loader's request have finished loading (whether successfully or not)
* @event complete
* @param {Array} requests The list of consumed requests
*/
const EVT_COMPLETE = 'complete';

/**
* A status indicating that the loader has not yet started consuming requests
* @property pending
* @static
* @final
* @private
*/
const STATUS_PENDING = 'pending';

/**
* A status indicating that the loader has started consuming requests
* @property loading
* @static
* @final
* @private
*/
const STATUS_LOADING = 'loading';

/**
* A status indicating that the loader has finished consuming all requests
* @property complete
* @static
* @final
* @private
*/
const STATUS_COMPLETE = 'complete';

/**
* LoadRequest object encapsulates a request of a loader
*
* @private
*/
class LoadRequest {

    /**
    * @param {Object} params Parameters object, given by the constructor.
    * @param {Function} params.consume A function to be called (with this request as an argument) to consume the request
    * @param {Function} [params.onStart = null] A function to be called when the loading starts
    * @param {Function} [params.onLoad = null] A function to be called when the loading finishes
    * @param {Function} [params.onError = null] A function to be called when the loading fails}
     */
    constructor({
        consume = () => { },
        onStart = undefined,
        onLoad = undefined,
        onError = undefined,
        url = undefined
    } = {}) {

        this.consume = consume;
        this.onStart = onStart;
        this.onLoad = onLoad;
        this.onError = onError;
        this.url = url;

        this._value = undefined;
        this._error = undefined;

        this.events = new EventEmitter({ "scope": this });

        if (this.onStart) {
            this.events.on(EVT_REQUEST_START, this.onStart);
        }
        if (this.onLoad) {
            this.events.on(EVT_REQUEST_LOAD, this.onLoad);
        }
        if (this.onError) {
            this.events.on(EVT_REQUEST_ERROR, this.onError);
        }
    }

    /**
    * Set the value of this LoadRequest. Will be filled after loading completion
    * @method setValue
    * @param {Object} value
    */
    setValue(value) {
        this._value = value;
    }

    /**
    * Get the value of this LoadRequest
    * @method getValue
    * @return {Mixed} value
    */
    getValue() {
        return this._value;
    }

    /**
    * Set the error that occured if any
    * @method setError
    * @param {Mixed} error
    */
    setError(error) {
        this._error = error;
    }

    /**
    * Get the error that occured if any
    * @method getError
    * @return {Mixed} error
    */
    getError() {
        return this._error;
    }
}

/**
* A loader class that manage the loading of datas from url. This is mainly used internally to manage the preLoad method in users script. Users should usually use this class methods to load datas and obtain the result encapsulated in a LoadRequest object.
*/
export default class Loader {

    /**
    * @param {Object} params Parameters object, given by the constructor.
    * @param {Function} params.onLoad
    * @param {Function} params.onError
    * @param {Function} params.onComplete
    */
    constructor({
        onLoad = null,
        onError = null,
        onComplete = null,
    } = {}) {

        this.onLoad = onLoad;
        this.onError = onError;
        this.onComplete = onComplete;

        this._status = STATUS_PENDING;
        this._requests = [];
        this._complete = 0;

        this.events = new EventEmitter({ "scope": this });

        if (this.onLoad) {
            this.events.on(EVT_LOAD, this.onLoad);
        }
        if (this.onError) {
            this.events.on(EVT_ERROR, this.onError);
        }
        if (this.onComplete) {
            this.events.on(EVT_COMPLETE, this.onComplete);
        }
    }

    /**
    * Consume (executes) all the requests
    * @method consumeAll
    */
    consumeAll() {
        if (this._requests.length > 0) {
            this._status = STATUS_LOADING;

            for (const request of this._requests) {
                request.consume(request);
            }
        }
        else {
            this.doComplete();
        }
    }

    /**
    * Helper method to update the status and trigger the complete event
    * @private
    */
    doComplete() {
        this._status = STATUS_COMPLETE;
        this.events.trigger(EVT_COMPLETE, this._requests);
    }

    /**
    * Loads data from a URL. This static method must be provided a callback method.
    * @static

    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    * @param {Mixed} callback.result The load result if successful
    * @param {String} callback.error The load error if unsuccessful
    * @param {String} [responseType] The type of the respons to return from the Ajax call
    * @param {Function} [processData] function definning what to do with the data coming from the promise
    */
    static load(url, callback, responseType, processData) {

        //check if the url is aleady in cache for loading
        if (Loader.Cache.isLoading(url)) {
            if (callback) {
                Loader.Cache.addCallback(url, callback);
                return null;
            }
        }
        //check if the file is already cached (loaded)
        if (Loader.Cache.isCached(url)) {
            debug.info(`ressource ${url} is cached and won't be loaded again`);
            return Loader.Cache.get(url);
        }

        //add this url to the file to cache
        Loader.Cache.addKey(url);

        const ajax = new Ajax({
            url,
            responseType,
            "autoSend": false
        });

        if (processData === undefined) {
            processData = (ajx) => {
                return ajx.getResponse();
            };
        }

        ajax.events.on("success", (ajx) => {
            const result = processData(ajx);
            callback(result);
            //this is the loading response whithout further process,
            //add it to the cache and pass the response to all other registred callback
            Loader.Cache.addValue(url, result);
        });

        ajax.events.on("error", (ajx) => {
            callback(undefined, ajx.getStatusText());
        });

        ajax.send();

        return null;
    }

    /**
    * Loads a text from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    * @param {Mixed} callback.result The load result if successful
    * @param {String} callback.error The load error if unsuccessful
    */
    static loadText(url, callback) {
        Loader.load(url, callback, "text");
    }

    /**
    * Loads a JavaScript object, parsed from a JSON string from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    * @param {Mixed} callback.result The load result if successful
    * @param {String} callback.error The load error if unsuccessful
    */
    static loadJSON(url, callback) {
        Loader.load(url, callback, "json");
    }

    /**
    * Loads an arraybuffer from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    * @param {Mixed} callback.result The load result if successful
    * @param {String} callback.error The load error if unsuccessful
    */
    static loadArrayBuffer(url, callback) {
        Loader.load(url, callback, "arraybuffer");
    }

    /**
    * Loads a blob from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    */
    static loadBlob(url, callback) {
        Loader.load(url, callback, "blob");
    }

    /**
    * Loads an image from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    * @param {Mixed} callback.result The load result if successful
    * @param {String} callback.error The load error if unsuccessful
    */
    static loadImage(url, callback) {
        const img = new Image();

        img.addEventListener("load", () => {
            callback(img);
        }, false);

        img.addEventListener("error", () => {
            callback(undefined, `Error loading the image ${url}`);
        }, false);

        img.src = url;
    }

    /**
    * Loads a script from a URL. This static method must be provided a callback method.

    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion
    */
    static loadScript(url, callback) {
        const script = document.createElement("script");
        document.head.appendChild(script);
        script.addEventListener("load", callback(script));
        script.src = url;
    }

    /**
    * @TODO A DEPLACER DANS RENDERER
    * Loads an obj model from a URL. This static method must be provided a callback method. Mtl files are not supported with this method. Form mtl support, use the loadOBJ (not static) method of Loader.
    * @method loadOBJ
    * @static
    * @param {String} url the URL to load data from
    * @param {Function} callback callback function that the user wants to call on load completion. It receives the Mesh resulting from the loading.
    */
    /* static loadOBJ(url, callback)
    {
        Loader.loadText(url, (objText) => {

            const mesh = OBJ.parseOBJ(objText);
            callback(mesh);

        }, "text");
    } */

    /**
    * Load a data from the URL given in parameters. ResponseType is the default one of an Ajax object, that is String.
    *
    * @param {String} url the URL to load data from
    * @param {Function} [onStart = null] A function to be called when the loading starts
    * @param {Function} [onLoad = null] A function to be called when the loading finishes
    * @param {Function} [onError = null] A function to be called when the loading fails
    * @param {String} [responseType] The type of the respons to return from the Ajax call
    * @param {Function} processData function definning what to do with the data coming from the promise
    * @return {LoadRequest} the LoadRequest object is incomplete at call and will be filled by the inner promise manager, when fulfilled.
    */
    load({
        url = undefined,
        onStart = undefined,
        onLoad = undefined,
        onError = undefined,
        responseType = "",
        processData = (ajax) => {
            return ajax.getResponse();
        },
    } = {}) {
        const ajax = new Ajax({
            url,
            responseType,
            "autoSend": false
        });

        const request = new LoadRequest({
            "consume": () => {
                ajax.send();
            },
            url,
            onStart,
            onLoad,
            onError
        });

        ajax.events.on("start", () => {
            request.events.trigger(EVT_REQUEST_START);
        });

        ajax.events.on("success", (ajx) => {
            const value = processData(ajx, this);

            request.setValue(value);
            request.events.trigger(EVT_REQUEST_LOAD, value);

            this.events.trigger(EVT_LOAD, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete("this._complete", this._complete, "this._requests.length", this._requests.length);
            }
        });

        ajax.events.on("error", (ajx) => {
            const error = ajx.getStatusText();

            request.setError(error);
            request.events.trigger(EVT_REQUEST_ERROR, error);

            this.events.trigger(EVT_ERROR, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        });

        this._requests.push(request);

        if (this._status === STATUS_LOADING) {
            request.consume();
        }

        return request;
    }

    /**
    * Loads a text from a URL.
    *
    * @param {Object} params Parameters object to pass to the load function.
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the Ajax request is fulfilled
    */
    loadText(params) {
        return this.load(Object.assign({}, params, {
            responseType: "text"
        }));
    }

    /**
    * Loads a JavaScript object, parsed from a JSON string from a URL.
    *
    * @param {Object} params Parameters object to pass to the load function.
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the Ajax request is fulfilled
    */
    loadJSON(params) {
        return this.load(Object.assign({}, params, {
            responseType: "json"
        }));
    }

    /**
    * Load an image from the URL given in parameters. The Image Object is given as the returned LoadRequest's value.
    * @method loadImage
    * @param {String} url the URL to load data from
    * @param {Function} [onStart = null] A function to be called when the loading starts
    * @param {Function} [onLoad = null] A function to be called when the loading finishes
    * @param {Function} [onError = null] A function to be called when the loading fails
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the Ajax request is fulfilled
    */
    loadImage({
        url = undefined,
        onStart = undefined,
        onLoad = undefined,
        onError = undefined,
    } = {}) {
        const img = new Image();

        const request = new LoadRequest({
            "consume": (req) => {
                img.src = url;
                req.events.trigger(EVT_REQUEST_START);
            },
            url,
            onStart,
            onLoad,
            onError
        });

        img.addEventListener("load", () => {
            request.setValue(img);
            request.events.trigger(EVT_REQUEST_LOAD, img);

            this.events.trigger(EVT_LOAD, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        }, false);

        img.addEventListener("error", () => {
            const error = `Error loading the image ${url}`;

            request.setError(error);
            request.events.trigger(EVT_REQUEST_ERROR, error);

            this.events.trigger(EVT_ERROR, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        });

        this._requests.push(request);

        if (this._status === STATUS_LOADING) {
            request.consume();
        }

        return request;
    }

    /**
    * Load a video from the URL given in parameters. The Video Object is given as the returned LoadRequest's value.
    * @method loadVideo
    * @param {String} url the URL to load data from
    * @param {Function} [onStart = null] A function to be called when the loading starts
    * @param {Function} [onLoad = null] A function to be called when the loading finishes
    * @param {Function} [onError = null] A function to be called when the loading fails
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the request is fulfilled
    */
    loadVideo({
        url = undefined,
        onStart = undefined,
        onLoad = undefined,
        onError = undefined,
    } = {}) {
        const video = document.createElement("video");
        video.autoplay = false;

        const request = new LoadRequest({
            "consume": (req) => {
                video.src = url;
                video.load();
                req.events.trigger(EVT_REQUEST_START);
            },
            url,
            onStart,
            onLoad,
            onError
        });

        video.addEventListener("canplay", () => {
            request.setValue(video);
            request.events.trigger(EVT_REQUEST_LOAD, video);

            this.events.trigger(EVT_LOAD, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        }, false);

        video.addEventListener("error", () => {
            const error = `Error loading the video ${url} ${video.error.code}`;

            debug.log(video.error);

            request.setError(error);
            request.events.trigger(EVT_REQUEST_ERROR, error);

            this.events.trigger(EVT_ERROR, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        });

        this._requests.push(request);

        if (this._status === STATUS_LOADING) {
            request.consume();
        }

        return request;
    }

    loadScript({
        url = undefined,
        onStart = undefined,
        onLoad = undefined,
        onError = undefined,
    } = {}) {
        const script = document.createElement("script");
        document.head.appendChild(script);
        script.src = url;

        const request = new LoadRequest({
            "consume": (req) => {
                script.src = url;
                req.events.trigger(EVT_REQUEST_START);
            },
            url,
            onStart,
            onLoad,
            onError
        });

        script.addEventListener("load", () => {
            request.setValue(script);
            request.events.trigger(EVT_REQUEST_LOAD, script);

            this.events.trigger(EVT_LOAD, request);

            this._complete++;

            if (this._complete === this._requests.length) {
                this.doComplete();
            }
        }, false);

        this._requests.push(request);

        if (this._status === STATUS_LOADING) {
            request.consume();
        }

        return request;
    }

    /**
    * Loads an ArrayBuffer from a URL.
    *
    * @param {Object} params Parameters object to pass to the load function.
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the Ajax request is fulfilled
    */
    loadArrayBuffer(params) {
        return this.load(Object.assign({}, params, {
            responseType: "arraybuffer"
        }));
    }

    /**
    * Loads a blob from a URL.
    *
    * @param {Object} params Parameters object to pass to the load function.
    * @return {LoadRequest} a LoadRequest who's value is incomplete at first and will be filled once the Ajax request is fulfilled
    */
    loadBlob(params) {
        return this.load(Object.assign({}, params, {
            responseType: "blob"
        }));
    }
}

class Cache {
    /**
    * Cache gives the possibility to automatically avoid the reloading of the same ressource. It uses url to check if a given ressource has already been loaded or if it's currently loading.
    * Cache exists only once as a static class of Loader and should never be created by user.
    * Cache is enabled by default. To disable it, use Loader.Cache.enable(false) before you start using a Loader or a component that uses a Loader internally (like Button, RichText, etc.)
    * It is used only in Loader static methods for now (to fix!).
    * @class Cache
    * @constructor
    */
    constructor() {
        this.enabled = true;
        this.files = {};
        this.callbacks = [];
    }

    /**
    * Enable or disable cache
    * @method enable
    * @param {Boolean} enabled
    */
    enable(enabled = false) {
        this.enabled = enabled;
    }

    /**
    * Add a file to the cache with the given key
    * @method add
    * @param {Object} key
    * @param {Object} file
    */
    add(key, file) {
        if (this.enabled === true && !this.getKey(key)) {
            debug.info('Cache Adding key and file:', key, file);
            this.files[key] = file;
        }
    }

    /**
    * Add a key to the cache with no value, for further completion
    * @method addKey
    * @param {Object} key
    */
    addKey(key) {
        if (this.enabled === true && !this.getKey(key)) {
            debug.info('Cache Adding key:', key);
            this.files[key] = undefined;
        }
    }

    /**
    * Add a value to the cache to the given, already existing key
    * @method addValue
    * @param {Object} key
    * @param {Object} file
    */
    addValue(key, file) {
        if (this.enabled === false) {
            return;
        }

        if (this.getKey(key) && this.files[key] === undefined) {
            if (this.getKey(key) && this.files[key] === undefined) {
                debug.info('Cache Adding value:', file, "to key", key);
                this.files[key] = file;

                //process then remove callback consumed
                for (let i = this.callbacks.length - 1; i >= 0; i--) {
                    if (this.callbacks[i].key === key) {
                        const callback = this.callbacks[i].callback;
                        callback(file);

                        this.callbacks.splice(i, 1);
                    }
                }
                debug.log("this.callbacks.length", this.callbacks.length);
            }
        }
    }

    /**
    * Get the value of the given key
    * @method get
    * @param {Object} key
    * @return {Object} the file in cache
    */
    get(key) {
        if (this.enabled === false) {
            return null;
        }
        return this.files[key];
    }

    /**
    * Remove the given key
    * @method remove
    * @param {Object} key
    */
    remove(key) {
        delete this.files[key];
    }

    /**
    * Check if the file of the given key is aleady cached
    * @method isCached
    * @param {Object} key
    * @return {Boolean} is cached or not
    */
    isCached(key) {
        return (this.files.hasOwnProperty(key) && typeof this.files[key] !== 'undefined');
    }

    /**
    * Check if the file of the given key is loading (only the key exists, not the value)
    * @method isLoading
    * @param {Object} key
    * @return {Boolean} is loading or not
    */
    isLoading(key) {
        return (this.files.hasOwnProperty(key) && typeof this.files[key] === 'undefined');
    }

    /**
    * Get the key if it exists, even with no value
    * @method getKey
    * @param {Object} key
    * @return {Object} the key or undefined
    */
    getKey(key) {
        const keys = Object.keys(this.files);

        for (let k = 0; k < keys.length; k++) {
            if (keys[k] === key) {
                return keys[k];
            }
        }
        return undefined;
    }

    /**
    * Empty the file list
    * @method clear
    */
    clear() {
        this.files = {};
    }

    /**
    * Adds a callback coming from a Loader, with its associated key. We use a list of object as we must be able to register several callback that can have the same key.
    * @method addCallback
    * @param {Object} key
    * @param {Function} callback
    */
    addCallback(key, callback) {
        const temp = { key, callback };
        this.callbacks.push(temp);
    }
}

Loader.Cache = new Cache();