base/Base.js

/*
 * Copyright 2020 WICKLETS LLC
 *
 * This file is part of Wick Engine.
 *
 * Wick Engine 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.
 *
 * Wick Engine 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 Wick Engine.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * The base class for all objects within the Wick Engine.
 */
Wick.Base = class {
    /**
     * Creates a Base object.
     * @parm {string} identifier - (Optional) The identifier of the object. Defaults to null.
     * @parm {string} name - (Optional) The name of the object. Defaults to null.
     */
    constructor(args) {
        /* One instance of each Wick.Base class is created so we can access
         * a list of all possible properties of each class. This is used
         * to clean up custom variables after projects are stopped. */
        if (!Wick._originals[this.classname]) {
            Wick._originals[this.classname] = {};
            Wick._originals[this.classname] = new Wick[this.classname];
        }

        if (!args) args = {};

        this._uuid = args.uuid || uuidv4();
        this._identifier = args.identifier || null;
        this._name = args.name || null;

        this._view = null;
        this.view = this._generateView();

        this._guiElement = null;
        this.guiElement = this._generateGUIElement();

        this._classname = this.classname;

        this._children = [];
        this._childrenData = null;
        this._parent = null;

        // If this is a project, use this object, otherwise use the passed in project if provided.
        this._project = this.classname === 'Project' ? this : args.project ? args.project : null;

        this.needsAutosave = true;
        this._cachedSerializeData = null;
        this._temporary = false; // Defines if this object is "temporary"

        Wick.ObjectCache.addObject(this);
    }

    /**
     * @param {object} data - Serialized data to use to create a new object.
     */
    static fromData(data, project) {
        if (!data.classname) {
            console.warn('Wick.Base.fromData(): data was missing, did you mean to deserialize something else?');
        }
        if (!Wick[data.classname]) {
            console.warn('Tried to deserialize an object with no Wick class: ' + data.classname);
        }

        var object = new Wick[data.classname]({ uuid: data.uuid, project: project });
        object.deserialize(data);

        if (data.classname === 'Project') {
            object.initialize();
        }

        return object;
    }

    /**
     * Converts this Wick Base object into a plain javascript object contianing raw data (no references).
     * @return {object} Plain JavaScript object representing this Wick Base object.
     */
    serialize(args) {
        // TEMPORARY: Force the cache to never be accessed.
        // This is because the cache was causing issues in the tests, and the
        // performance boost that came with the cache was not signifigant enough
        // to be worth fixing the bugs over...
        this.needsAutosave = true;

        if (this.needsAutosave || !this._cachedSerializeData) {
            // If the cache is outdated or does not exist, reserialize and cache.
            var data = this._serialize(args);
            this._cacheSerializeData(data);
            return data;
        } else {
            // Otherwise, just read from the cache
            return this._cachedSerializeData;
        }
    }

    /**
     * Parses serialized data representing Base Objects which have been serialized using the serialize function of their class.
     * @param {object} data Serialized data that was returned by a Base Object's serialize function.
     */
    deserialize(data) {
        this._deserialize(data);
        this._cacheSerializeData(data);
    }

    /* The internal serialize method that actually creates the data. Every class that inherits from Base must have one of these. */
    _serialize(args) {
        var data = {};

        data.classname = this.classname;
        data.identifier = this._identifier;
        data.name = this._name;
        data.uuid = this._uuid;
        data.children = this.getChildren().map(child => { return child.uuid });

        return data;
    }

    /* The internal deserialize method that actually reads the data. Every class that inherits from Base must have one of these. */
    _deserialize(data) {
        this._uuid = data.uuid;
        this._identifier = data.identifier;
        this._name = data.name;
        this._children = [];
        this._childrenData = data.children;

        // Clear any custom attributes set by scripts
        var compareObj = Wick._originals[this.classname];
        for (var name in this) {
            if (compareObj[name] === undefined) {
                delete this[name];
            }
        }
    }

    _cacheSerializeData(data) {
        this._cachedSerializeData = data;
        this.needsAutosave = false;
    }

    /**
     * Returns a copy of a Wick Base object.
     * @return {Wick.Base} The object resulting from the copy
     */
    copy() {
        var data = this.serialize();
        data.uuid = uuidv4();
        var copy = Wick.Base.fromData(data);

        copy._childrenData = null;

        // Copy children
        this.getChildren().forEach(child => {
            copy.addChild(child.copy());
        });

        return copy;
    }

    /**
     * Returns an object containing serialied data of this object, as well as all of its children.
     * Use this to copy entire Wick.Base objects between projects, and to export individual Clips as files.
     * @returns {object} The exported data.
     */
    export () {
        var copy = this.copy();
        copy._project = this.project;

        // the main object
        var object = copy.serialize();

        // children
        var children = copy.getChildrenRecursive().map(child => {
            return child.serialize();
        });

        // assets
        var assets = [];
        copy.getChildrenRecursive().concat(copy).forEach(child => {
            child._project = copy._project;
            child.getLinkedAssets().forEach(asset => {
                assets.push(asset.serialize({ includeOriginalSource: true }));
            });
        });

        return {
            object: object,
            children: children,
            assets: assets,
        };
    }

    /**
     * Import data created using Wick.Base.export().
     * @param {object} exportData - an object created from Wick.Base.export().
     */
    static
    import (exportData, project) {
        if (!exportData) console.error('Wick.Base.import(): exportData is required');
        if (!exportData.object) console.error('Wick.Base.import(): exportData is missing data');
        if (!exportData.children) console.error('Wick.Base.import(): exportData is missing data');


        // Import assets first in case the objects need them!
        exportData.assets.forEach(assetData => {
            // Don't import assets if they exist in the project already
            // (Assets only get reimported when objects are pasted between projects)
            if (project.getAssetByUUID(assetData.uuid)) {
                return;
            }

            var asset = Wick.Base.fromData(assetData, project);
            project.addAsset(asset);
        });

        var object = Wick.Base.fromData(exportData.object, project);

        // Import children as well
        exportData.children.forEach(childData => {
            // Only need to call deserialize here, we just want the object to get added to ObjectCache
            var child = Wick.Base.fromData(childData, project);
        });

        return object;
    }

    /**
     * Marks the object as possibly changed, so that next time autosave happens, this object is written to the save.
     * @type {boolean}
     */
    set needsAutosave(needsAutosave) {
        if (needsAutosave) {
            Wick.ObjectCache.markObjectToBeAutosaved(this);
        } else {
            Wick.ObjectCache.clearObjectToBeAutosaved(this);
        }
    }

    get needsAutosave() {
        return Wick.ObjectCache.objectNeedsAutosave(this);
    }

    /**
     * Signals if an object is removed from the project while playing.
     * This is a temprary variable.
     * @type {boolean}
     */
    get removed () {
        return typeof this._removed === 'undefined' ? false : this._removed;
    }

    set removed (bool) {
        this._removed = bool; 
    }


    /**
     * Returns the classname of a Wick Base object.
     * @type {string}
     */
    get classname() {
        return 'Base';
    }

    /**
     * A marker if this object is temporary. Meaning it 
     * should be garbage collected after a play.
     */
    get temporary() {
        return this._temporary;
    }

    /**
     * The uuid of a Wick Base object.
     * @type {string}
     */
    get uuid() {
        return this._uuid;
    }

    /**
     * Changes an object's uuid. This function should not be used consistently, as it creates an entire copy of the object
     * in the object cache. Avoid using this if possible.
     */
    set uuid(uuid) {
        this._uuid = uuid;
        Wick.ObjectCache.addObject(this);
    }

    /**
     * The name of the object that is used to access the object through scripts. Must be a valid JS variable name.
     * @type {string}
     */
    get identifier() {
        return this._identifier;
    }

    set identifier(identifier) {
        // Treat empty string identifier as null
        if (identifier === '' || identifier === null) {
            this._identifier = null;
            return;
        }

        // Make sure the identifier doesn't squash any attributes of the window
        if (this._identifierNameExistsInWindowContext(identifier)) return;

        // Make sure the identifier will not be squashed by Wick API functions
        if (this._identiferNameIsPartOfWickAPI(identifier)) return;

        // Make sure the identifier is a valid js variable name
        if(!isVarName(identifier)) {
            this.project && this.project.errorOccured('Identifier must be a valid variable name.');
            return;
        }

        // Make sure the identifier is not a reserved word in js
        if (reserved.check(identifier)) return;

        // Ensure no objects with duplicate identifiers can exist
        this._identifier = this._getUniqueIdentifier(identifier);
    }

    /**
     * The name of the object.
     * @type {string}
     */
    get name() {
        return this._name;
    }

    set name(name) {
        if (typeof name !== 'string') return;
        if (name === '') this._name = null;
        this._name = name;
    }

    /**
     * The Wick.View object that is used for rendering this object on the canvas.
     */
    get view() {
        return this._view;
    }

    set view(view) {
        if (view) view.model = this;
        this._view = view;
    }

    /**
     * The object that is used for rendering this object in the timeline GUI.
     */
    get guiElement() {
        return this._guiElement;
    }

    set guiElement(guiElement) {
        if (guiElement) guiElement.model = this;
        this._guiElement = guiElement;
    }

    /**
     * Returns a single child of this object with a given classname.
     * @param {string} classname - the classname to use
     */
    getChild(classname) {
        return this.getChildren(classname)[0];
    }

    /**
     * Gets all children with a given classname(s).
     * @param {Array|string} classname - (optional) A string, or list of strings, of classnames.
     */
    getChildren(classname) {
        // Lazily generate children list from serialized data
        if (this._childrenData) {
            this._childrenData.forEach(uuid => {
                this.addChild(Wick.ObjectCache.getObjectByUUID(uuid));
            });
            this._childrenData = null;
        }

        if (classname instanceof Array) {
            let classNames = new Set(classname);
            var children = [];

            if (this._children !== undefined) {
                children = this._children.filter(child => classNames.has(child.classname));
            }

            return children;
        } else if (classname === undefined) {
            // Retrieve all children if no classname was given
            return Array.from(this._children);
        } else {
            // Retrieve children by classname
            var children = this._children.filter(child => child.classname === classname);
            return children || [];
        }
    }

    /**
     * Get an array of all children of this object, and the children of those children, recursively.
     * @type {Wick.Base[]}
     */
    getChildrenRecursive(level, original) {
        var children = this.getChildren();

        this.getChildren().forEach(child => {
            children = children.concat(child.getChildrenRecursive(level + 1, original));
        });

        return children;
    }

    /**
     * The parent of this object.
     * @type {Wick.Base}
     */
    get parent() {
        return this._parent;
    }

    /**
     * The parent Clip of this object.
     * @type {Wick.Clip}
     */
    get parentClip() {
        return this._getParentByClassName('Clip');
    }

    /**
     * The parent Layer of this object.
     * @type {Wick.Layer}
     */
    get parentLayer() {
        return this._getParentByClassName('Layer');
    }

    /**
     * The parent Frame of this object.
     * @type {Wick.Frame}
     */
    get parentFrame() {
        return this._getParentByClassName('Frame');
    }

    /**
     * The parent Timeline of this object.
     * @type {Wick.Timeline}
     */
    get parentTimeline() {
        return this._getParentByClassName('Timeline');
    }

    /**
     * The project that this object belongs to. Can be null if the object is not in a project.
     * @type {Wick.Project}
     */
    get project() {
        if (this._project) {
            return this._project;
        } else if (this.parent) {
            return this.parent.project
        } else {
            return null;
        }
    }

    /**
     * Check if an object is selected or not.
     * @type {boolean}
     */
    get isSelected() {
        if (!this.project) return false;
        return this.project.selection.isObjectSelected(this);
    }

    /**
     * Add a child to this object.
     * @param {Wick.Base} child - the child to add.
     */
    addChild(child) {
        var classname = child.classname;

        if (!this._children) {
            this._children = [];
        }

        child._parent = this;
        child._setProject(this.project);

        this._children.push(child);
    }

    /**
     * Insert a child into this at specified index.
     * 
     * The insertion is performed as if there is a dummy object placed
     * at index, then the child is moved one index past the dummy,
     * then the dummy is deleted. Therefore if the child starts out
     * inside this._children below index, then insertChild returns true and
     * this._children.indexOf(child) == index - 1. Otherwise, returns false and
     * this._children.indexOf(child) == index.
     * 
     * @param {Wick.Base} child - the child to add.
     * @param {number} index - where to add the child
     * @returns {boolean} - true if an item before index was moved
     */
    insertChild(child, index) {
        var classname = child.classname;

        if (child._parent === this) {
            let result = 0;
            let old_index = this._children.indexOf(child);
            if (old_index < index) {
                index --;
                result = 1;
            }
            this._children.splice(index, 0, this._children.splice(old_index, 1)[0]);
            return result;
        }

        if (child._parent) {
            child._parent.removeChild(child);
        }

        if (!this._children) {
            this._children = [];
        }

        child._parent = this;
        child._setProject(this.project);

        this._children.splice(index, 0, child);
        return 0;
    }

    /**
     * Remove a child from this object.
     * @param {Wick.Base} child - the child to remove.
     */
    removeChild(child) {
        if (!this._children) {
            return;
        }

        child._parent = null;
        child._project = null;

        this._children = this._children.filter(seekChild => {
            return seekChild !== child;
        });
    }

    /**
     * Assets attached to this object.
     * @returns {Wick.Base[]}
     */
    getLinkedAssets () {
        // Implemented by Wick.Frame and Wick.Clip
        return [];
    }

    _generateView() {
        var viewClass = Wick.View[this.classname];
        if (viewClass) {
            return new viewClass(this);
        } else {
            return null;
        }
    }

    _generateGUIElement() {
        var guiElementClass = Wick.GUIElement[this.classname];
        if (guiElementClass && guiElementClass !== Wick.Button) {
            return new guiElementClass(this);
        } else {
            return null;
        }
    }

    _getParentByClassName(classname) {
        if (!this.parent) return null;

        if (this.parent instanceof Wick[classname]) {
            return this.parent;
        } else {
            if (!this.parent._getParentByClassName) return null;
            return this.parent._getParentByClassName(classname);
        }
    }

    _setProject(project) {
        this._project = project;
        this.getChildren().forEach(child => {
            if (child instanceof Wick.Base) {
                child._setProject(project);
            }
        });
    }

    _getUniqueIdentifier(identifier) {
        if (!this.parent) return identifier;

        var otherIdentifiers = this.parent.getChildren(['Clip', 'Frame', 'Button']).filter(child => {
            return child !== this && child.identifier;
        }).map(child => {
            return child.identifier;
        });

        if (otherIdentifiers.indexOf(identifier) === -1) {
            return identifier;
        } else {
            return this._getUniqueIdentifier(identifier + '_copy');
        }
    }

    _identifierNameExistsInWindowContext(identifier) {
        if (window[identifier]) {
            return true;
        } else {
            return false;
        }
    }

    _identiferNameIsPartOfWickAPI(identifier) {
        var globalAPI = new GlobalAPI(this);
        if (globalAPI[identifier]) {
            return true;
        } else {
            return false;
        }
    }
}