History.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/>.
 */

/**
 * History utility class for undo/redo functionality.
 */
Wick.History = class {
    /**
     *
     * @type {boolean}
     */
    static get VERBOSE () {
        return false;
    }

    /**
     * An Enum of all types of state saves.
     */
    static get StateType () {
        return {
            ALL_OBJECTS: 1,
            ALL_OBJECTS_WITHOUT_PATHS: 2,
            ONLY_VISIBLE_OBJECTS: 3,
        };
    }

    /**
     * Creates a new history object
     */
    constructor () {
        this.reset();
        this.lastHistoryPush = Date.now(); 
    }

    /**
     * Resets history in the editor. This is non-reversible.
     */
    reset () {
        this._undoStack = [];
        this._redoStack = [];
        this._snapshots = {};
    }

    /**
     * Returns all objects that are currently referenced by the history.
     * @returns {Set} uuids of all objects currently referenced in the history.
     */
    getObjectUUIDs () {
        let objects = new Set();

        for (let state of this._undoStack) {
            objects = new Set([...objects, ...state.objects]);
        }

        for (let state of this._redoStack) {
            objects = new Set([...objects, ...state.objects]);
        }

        return objects;
    }

    /**
     * Push the current state of the ObjectCache to the undo stack.
     * @param {number} filter - the filter to choose which objects to serialize. See Wick.History.StateType
     * @param {string} actionName - Optional: Name of the action conducted to generate this state. If no name is presented, "Unknown Action" is presented in its place.
     */
    pushState (filter, actionName) {
        this._redoStack = [];
        let now = Date.now();

        let state = this._generateState(filter);
        let objects = new Set(state.map(obj => obj.uuid));
        let stateObject = {
            state: this._generateState(filter), 
            objects: objects,
            actionName: actionName || "Unknown Action",
            timeSinceLastPush: now - this.lastHistoryPush,
        }

        this.lastHistoryPush = now;

        this._undoStack.push(stateObject);
        this._undoStack = this._undoStack.slice(-64); // get the last 64 items in the undo stack
    }

    /**
     * Pop the last state in the undo stack off and apply the new last state to the project.
     * @returns {boolean} True if the undo stack is non-empty, false otherwise
     */
    popState () {
        if(this._undoStack.length <= 1) {
            return false;
        }

        var lastState = this._undoStack.pop();
        this._redoStack.push(lastState);

        var currentStateObject = this._undoStack[this._undoStack.length - 1];

        // 1.17.1 History update, pull actual state information out, aside from names.
        var currentState = currentStateObject; 

        if (currentStateObject.state) {
            currentState = currentStateObject.state;
        }

        this._recoverState(currentState);

        return true;
    }

    /**
     * Recover a state that was undone.
     * @returns {boolean} True if the redo stack is non-empty, false otherwise
     */
    recoverState () {
        if(this._redoStack.length === 0) {
            return false;
        }

        var recoveredState = this._redoStack.pop().state;
        this._undoStack.push(recoveredState);

        this._recoverState(recoveredState);

        return true;
    }

    /**
     *
     * @param {string} name - the name of the snapshot
     * @param {number} filter - the filter to choose which objects to serialize. See Wick.History.StateType
     */
    saveSnapshot (name, filter) {
        this._snapshots[name] = this._generateState(filter || Wick.History.StateType.ALL_OBJECTS_WITHOUT_PATHS);
    }

    /**
     * Save a state to the list of snapshots to be recovered at any time.
     * @param {string} name - the name of the snapshot to recover
     */
    loadSnapshot (name) {
        this._recoverState(this._snapshots[name]);
    }

    /**
     * The number of states currently stored for undoing.
     * @type {number}
     */
    get numUndoStates () {
        return this._undoStack.length;
    }

    /**
     * The number of states currently stored for redoing.
     * @type {number}
     */
    get numRedoStates () {
        return this._redoStack.length;
    }

    // NOTE: State saving/recovery can be greatly optimized by only saving the state of the things that were actually changed.
    _generateState (stateType) {
        var objects = [];

        if(stateType === undefined) {
            stateType = Wick.History.StateType.ALL_OBJECTS;
        }

        if(stateType === Wick.History.StateType.ALL_OBJECTS) {
            objects = this._getAllObjects();
        } else if (stateType === Wick.History.StateType.ALL_OBJECTS_WITHOUT_PATHS) {
            objects = this._getAllObjectsWithoutPaths();
        } else if(stateType === Wick.History.StateType.ONLY_VISIBLE_OBJECTS) {
            objects = this._getVisibleObjects();
        } else {
            console.error('Wick.History._generateState: A valid stateType is required.');
            return;
        }

        if(Wick.History.VERBOSE) {
            console.log('Wick.History._generateState: Serializing ' + objects.length + ' objects using mode=' + stateType);
        }

        return objects.map(object => {
            // The object most likely was altered in some way, make sure those changes will be reflected in the autosave.
            object.needsAutosave = true;

            return object.serialize();
        });
    }

    _recoverState (state) {
        state.forEach(objectData => {
            var object = Wick.ObjectCache.getObjectByUUID(objectData.uuid);
            object.deserialize(objectData);
        });
    }

    _getAllObjects () {
        var objects = Wick.ObjectCache.getActiveObjects(this.project);
        objects.push(this.project);
        return objects;
    }

    // this is used for an optimization when snapshots are saved for preview playing.
    _getAllObjectsWithoutPaths () {
        return this._getAllObjects().filter(object => {
            return !(object instanceof Wick.Path);
        });
    }

    _getVisibleObjects () {
        var stateObjects = [];

        // the project itself (for focus, options, etc)
        stateObjects.push(this.project);

        // the assets in the project
        this.project.getAssets().forEach(asset => {
            stateObjects.push(asset);
        });

        // the focused clip
        stateObjects.push(this.project.focus);

        // the focused timeline
        stateObjects.push(this.project.focus.timeline);

        // the selection
        stateObjects.push(this.project.selection);

        // layers on focused timeline
        this.project.activeTimeline.layers.forEach(layer => {
            stateObjects.push(layer);
        });

        // frames on focused timeline
        this.project.activeTimeline.frames.forEach(frame => {
            stateObjects.push(frame);
        });

        // objects+tweens on active frames
        this.project.activeFrames.forEach(frame => {
            frame.paths.forEach(path => {
                stateObjects.push(path);
            });
            frame.clips.forEach(clip => {
                stateObjects.push(clip);
            });
            frame.tweens.forEach(tween => {
                stateObjects.push(tween);
            })
        });

        return stateObjects;
    }
}