Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
jsarnowski/oxygen / angular / controllers / controller.undoredo.js
Size: Mime:
/**
 * Undo Redo Controller
 *
 * @author Abdelouahed E.
 * @since 3.2
 */

CTFrontendBuilder.controller("ControllerUndoRedo", function ($scope, $parentScope, $timeout, $interval) {
    // Store data that need to be tracked for changes
    var oldData = {};
    var data = {};

    // Used to skip actions during components rebuild
    var inPogress = false;

    // Used to pause/resume data watcher
    var trackChanges = true;

    // Setup undo manager
    var undoManager = new UndoManager();

    // Expose undoManager to scope
    $scope.undoManager = undoManager;
    $scope.pauseDataWatcher = pauseDataWatcher;
    $scope.resumeDataWatcher = resumeDataWatcher;
    $scope.watchIntervalCallback = watchIntervalCallback;

    // Initialize history items
    $scope.firstHistoryItem = {};
    $scope.historyItems = [];

    // Used to skip changes that shouldn't be tracked
    $scope.skipChanges = false;

    // Initialize undo/redo status to false
    $scope.canUndo = false;
    $scope.canRedo = false;

    // Expose undo command to iframeScope
    $scope.undo = function () {
        if (inPogress == false) {
            undoManager.undo();
        }
    };

    // Expose redo command to iframeScope
    $scope.redo = function () {
        if (inPogress == false) {
            undoManager.redo();
        }
    };

    // Debounce callback
    function debounce(callback, delay) {
        var timeout;
        return function () {
            var context = this;
            var args = arguments;
            if (timeout) {
                clearTimeout(timeout);
            }
            timeout = setTimeout(function () {
                timeout = null;
                callback.apply(context, args);
            }, delay);
        }
    }

    // Update undo/redo status on every action on the stack
    undoManager.setChangeCallback(function () {
        $scope.canUndo = undoManager.hasUndo();
        $scope.canRedo = undoManager.hasRedo();

        // Populate history items
        var history = [];
        var umIndex = undoManager.getIndex();
        var umCommands = undoManager.getCommands();

        // Update first hidtory item status
        $scope.firstHistoryItem.active = (umIndex == -1);

        // Add changes to the history array
        umCommands.forEach(function (command, index) {
            var item = {
                index: index,
                time: command.time.toLocaleTimeString(),
                name: command.name,
                active: umIndex == index,
            };

            history.push(item);
        });

        $scope.historyItems = history;
    });

    function doHistoryItem(command) {
        // Force update non object data from $scope
        collectData();

        var commandData = angular.copy(command.data);
        var newData = angular.copy(data);

        // Calculate data diff
        var diff = DeepDiff(newData, commandData);

        // If there any difference between the data collections, apply the diff and restore target state
        if (diff) {
            restoreData(diff);
        }
    }

    undoManager.setDoCallback(doHistoryItem);

    $scope.doFirstHistoryItem = function () {
        undoManager.setIndex(-1);
        doHistoryItem($scope.firstHistoryItem);
    };

    function restoreData(diff) {
        inPogress = true;
        var domChanged = false, optionsChanged = false;

        var rebuildLevel = Infinity;
        var domChangedPaths = [];
        var optionsChangedPaths = [];
        var tagChangedPaths = [];

        function applyDiffItem(d) {
            var path = d.path;

            // Set a flag to rebuild DOM if components tree was changed
            if (path[0] === "componentsTree") {
                var lastIndex = path.lastIndexOf('children');

                if (lastIndex > 0) {
                    if (path.includes("options")) {
                        optionsChanged = true;
                        var componentPath = path.slice(0, lastIndex + 2).join('.');
                        if (optionsChangedPaths.indexOf(componentPath) == -1) {
                            optionsChangedPaths.push(componentPath);
                        }
                        // Check if changed tag
                        var pathEnd = _.last(path);
                        if (pathEnd == "tag" || (pathEnd == "original" && (_.has(d.lhs, "tag") || _.has(d.rhs, "tag")))) {
                            if (tagChangedPaths.indexOf(componentPath) == -1) {
                                tagChangedPaths.push(componentPath);
                            }
                        }
                    } else {
                        domChanged = true;
                        rebuildLevel = Math.min(lastIndex, rebuildLevel);
                        var componentPath = path.slice(0, rebuildLevel).join('.');
                        if (domChangedPaths.indexOf(componentPath) == -1) {
                            domChangedPaths.push(componentPath);
                        }
                    }
                }
            }

            // Handle other $scope data
            switch (d.kind) {
                case 'EDIT':
                case 'NEW':
                    objectPath.set($scope, path, d.rhs);
                    break;

                case 'DELETE':
                    objectPath.del($scope, path);
                    break;

                case 'ARRAY':
                    var dd = d.item;
                    dd.path = path.concat(d.index);

                    applyDiffItem(dd);
                    break;
            }
        }

        diff.forEach(applyDiffItem);

        // componentsTree structure has been changed
        if (domChanged) {
            var componentsToRebuild = [];

            // Calculate components ids to rebuild
            if (rebuildLevel < Infinity) {
                domChangedPaths.forEach(function (path) {
                    var component = objectPath.get($scope, path, null);

                    if (component && typeof component.id == "number") {
                        var id = component.id;
                        if (typeof componentsToRebuild[id] == "undefined") {
                            componentsToRebuild[id] = component;
                        }
                    }
                });
            }

            // If componentsToRebuild is empty, use the root component
            if (componentsToRebuild.length == 0) {
                componentsToRebuild.push($scope.componentsTree);
            }

            // Rebuild DOM
            componentsToRebuild.forEach(function (component) {
                var id = component.id, name = component.name;
                var element = $scope.getComponentById(id);

                // If the change occurs in a repeater, rebuild the parent repeater
                if (element && element.closest('[ctdynamiclist]').length > 0) {
                    $scope.updateRepeaterQuery(id);
                }

                // If the change occurs in a slide, rebuild the parent slider
                if (name == "ct_slide") {
                    id = component.options.ct_parent;
                }

                $scope.rebuildDOM(id);
            });

            // Update Structure Panel
            $scope.updateDOMTreeNavigator();
        }

        // componentsTree options has been changed
        if (optionsChanged) {
            var rebuildAjaxComponents = [];
            // Apply components options
            optionsChangedPaths.forEach(function (path) {
                var component = objectPath.get($scope, path, null);
                var options = $scope.component.options;
                if (component && component.id && component.options) {
                    var id = component.id, tag = component.name;
                    // Save name and parent to restore them later
                    var nicename = options[id]['nicename'];
                    var ct_parent = options[id]['ct_parent'];
                    // Reset the options model and recreate it from the tree
                    options[id] = {};
                    $scope.applyComponentDefaultOptions(id, tag, component);
                    $scope.applyComponentSavedOptions(id, component);
                    $scope.applyModelOptions(id, tag);
                    // apply saved name and parent id again
                    options[id]['nicename'] = nicename;
                    options[id]['ct_parent'] = ct_parent;

                    // Rebuild API element with rebuildElementOnChange options
                    if (!domChanged && typeof $scope.componentsTemplates == "object") {
                        if ($scope.componentsTemplates.hasOwnProperty(tag)) {
                            var triggerOptions = $scope.componentsTemplates[tag].rebuildTriggerOptions;
                            if (triggerOptions) {
                                _.each(component.options.original, function (optionValue, optionName) {
                                    if (triggerOptions.includes(optionName) && !rebuildAjaxComponents[id]) {
                                        rebuildAjaxComponents[id] = tag;
                                    }
                                });
                            }
                        }
                    }
                }
            });

            rebuildAjaxComponents.forEach(function (tag, id) {
                $timeout(function () {
                    $scope.renderComponentWithAJAX('oxy_render_' + tag, id);
                });
            });

            if (!domChanged && tagChangedPaths.length) {
                tagChangedPaths.forEach(function (path) {
                    var component = objectPath.get($scope, path, null);
                    if (component) {
                        $scope.rebuildDOM(component.id);
                    }
                });
            }
        }

        // update cache
        $scope.classesCached = false;
        $scope.updateAllComponentsCacheStyles();

        // output CSS
        $scope.outputCSSOptions();
        $scope.outputPageSettingsCSS();

        // adjust the resize box
        $scope.adjustResizeBox();

        // Remove undo delete notification, a feature that will be removed
        $scope.cancelDeleteUndo();

        // Set a flag to skip the recent changes caused by data restore
        $scope.skipChanges = true;

        // Set progress status when all components are rebuilt
        $scope.waitOxygenTree(function () {
            inPogress = false;
        });
    }

    function addUndoRedoData(undoDiff, redoDiff) {
        var name = $scope.component.options[$scope.component.active.id]['nicename'] || $scope.component.active.name || 'Unknown';

        undoManager.add({
            time: new Date(),
            name: name,
            data: angular.copy(data),

            undo: function () {
                restoreData.call(this, undoDiff);
            },
            redo: function () {
                restoreData.call(this, redoDiff);
            }
        });
    }

    function collectData() {
        data = {
            // Active Component
            "component": {
                "active": $scope.component.active
            },

            // Componenets
            "componentsTree": $scope.componentsTree,

            // Classes
            "classes": $scope.classes,
            "currentClass": $scope.currentClass,
            "componentsClasses": $scope.componentsClasses,

            // Custom selectors
            "customSelectors": $scope.customSelectors,

            // Styles
            "styleSets": $scope.styleSets,
            "styleFolders": $scope.styleFolders,

            // Stylesheets
            "styleSheets": $scope.styleSheets,
            "currentActiveStylesheetFolder": $scope.currentActiveStylesheetFolder,

            // Page settings
            "pageSettingsMeta": $scope.pageSettingsMeta,

            // Global settings
            "globalSettings": $scope.globalSettings,
            "globalColorSets": $scope.globalColorSets,

            // Templates
            "easyPostsCustomTemplates": $scope.easyPostsCustomTemplates,
            "commentsListCustomTemplates": $scope.commentsListCustomTemplates,

            // Presets
            "elementPresets": $scope.elementPresets
        };
    }

    function pauseDataWatcher() {
        watchIntervalCallback();
        trackChanges = false;
    }

    function resumeDataWatcher() {
        trackChanges = true;
        watchIntervalCallback();
    }

    function watchIntervalCallback() {
        // Check if undo manager is paused
        if (trackChanges == false) {
            return;
        }

        // Check if undo actions are in progress
        if (inPogress) {
            return;
        }

        // Force update non object data from $scope
        collectData();

        // Copy current data to compare
        var newData = angular.copy(data);

        // return if both old and new data are equal
        if (_.isEqual(newData, oldData)) {
            return;
        }

        // Calculate data diff
        var undoDiff = DeepDiff(newData, oldData);
        var redoDiff = DeepDiff(oldData, newData);

        // return if no data diff found
        if (!undoDiff || !redoDiff) {
            return;
        }

        // Prevent registration of new history points when activating components
        var changedActiveOnly = true;
        for (var i in redoDiff) {
            if (redoDiff[i].path[0] != "component") {
                changedActiveOnly = false;
                break;
            }
        }

        if (changedActiveOnly) {
            return;
        }

        // Check if the changes must be skipped
        if ($scope.skipChanges) {
            $scope.skipChanges = false;
        } else {
            // Save undo/redo states
            addUndoRedoData(undoDiff, redoDiff);
        }

        // store current data as refernce to check for changes
        oldData = angular.copy(data);
    }

    // Start watching for data changes
    function watchIntervalStarter() {
        // Force update non object data from $scope
        collectData();

        // store current data as refernce to check for changes
        oldData = angular.copy(data);

        var date = new Date();
        $scope.firstHistoryItem = {
            index: -1,
            name: "Initial State",
            time: date.toLocaleTimeString(),
            data: angular.copy(data),
            active: true,
        };

        if ($scope.historyLimit) {
            undoManager.setLimit($scope.historyLimit);
        }
    
        // Start watching on interval
        $interval(watchIntervalCallback, 1000);
    }

    // Fired when the builder content is loaded
    $scope.$on("oxygen_components_loaded", function () {
        var loading = $interval(function () {
            // Check loading status before starting the data watcher
            if ($parentScope.overlaysCount == 0) {
                // Start data watcher
                watchIntervalStarter();
                // Cancel loading interval
                $interval.cancel(loading);
            }
        }, 100);
    });

    // Keyboard shortcuts
    function shortcutHandler(event) {
        // Stop event processing if it is repeating
        if (event.originalEvent.repeat) {
            return;
        }

        // Stop event processing if Control or Command keys are inactive
        if (!event.ctrlKey && !event.metaKey) {
            return;
        }

        // Stop event processing if it's target is not the body element
        if (event.target.nodeName != 'BODY') {
            return;
        }

        // Stop event processing if content editor is active
        if ($parentScope.isActiveActionTab('contentEditing')) {
            return;
        }

        // Process the shortcut events
        var processed = false;
        var key = event.key.toLowerCase();
        switch (key) {
            case 'z':
                if ($scope.canUndo) {
                    $scope.undo();
                    processed = true;
                }
                break;
            case 'y':
                if ($scope.canRedo) {
                    $scope.redo();
                    processed = true;
                }
                break;
        }

        // If the shortcut event was processed, stop the event propagation and cancel it
        if (processed) {
            event.stopImmediatePropagation();
            event.preventDefault();
        }
    }

    var keyDownCallback = debounce(shortcutHandler, 250);

    // Event listener on iframe body
    angular.element('body').on('keydown', keyDownCallback);

    // Event listener on builder body
    parent.angular.element('body').on('keydown', keyDownCallback);
});