divio/django-cms

View on GitHub
cms/static/cms/js/modules/cms.structureboard.js

Summary

Maintainability
F
1 wk
Test Coverage
/*
 * Copyright https://github.com/divio/django-cms
 */

import $ from 'jquery';
import keyboard from './keyboard';
import Plugin from './cms.plugins';
import { getPlaceholderIds } from './cms.toolbar';
import Clipboard from './cms.clipboard';
import URI from 'urijs';
import { DiffDOM } from 'diff-dom';
import PreventParentScroll from 'prevent-parent-scroll';
import { find, findIndex, once, remove, compact, isEqual, zip, every } from 'lodash';
import ls from 'local-storage';

import './jquery.ui.custom';
import './jquery.ui.touchpunch';
import './jquery.ui.nestedsortable';

import measureScrollbar from './scrollbar';
import preloadImagesFromMarkup from './preload-images';

import { Helpers, KEYS } from './cms.base';
import { showLoader, hideLoader } from './loader';

let dd;
const DOMParser = window.DOMParser; // needed only for testing
const storageKey = 'cms-structure';

let placeholders;
let originalPluginContainer;

const triggerWindowResize = () => {
    try {
        var evt = document.createEvent('UIEvents');

        evt.initUIEvent('resize', true, false, window, 0);
        window.dispatchEvent(evt);
    } catch (e) {}
};

const arrayEquals = (a1, a2) => every(zip(a1, a2), ([a, b]) => a === b);

/**
 * Handles drag & drop, mode switching and collapsables.
 *
 * @class StructureBoard
 * @namespace CMS
 */
class StructureBoard {
    constructor() {
        // elements
        this._setupUI();

        // states
        this.click = 'click.cms.structure';
        this.keyUpAndDown = 'keyup.cms.structure keydown.cms.structure';
        this.pointerUp = 'pointerup.cms';
        this.state = false;
        this.dragging = false;
        this.latestAction = [];
        ls.remove(storageKey);

        dd = new DiffDOM();

        // setup initial stuff
        const setup = this._setup();

        // istanbul ignore if
        if (typeof setup === 'undefined' && CMS.config.mode === 'draft') {
            this._preloadOppositeMode();
        }
        this._setupModeSwitcher();
        this._events();
        StructureBoard.actualizePlaceholders();

        setTimeout(() => this.highlightPluginFromUrl(), 0);
        this._listenToExternalUpdates();
    }

    /**
     * Stores all jQuery references within `this.ui`.
     *
     * @method _setupUI
     * @private
     */
    _setupUI() {
        var container = $('.cms-structure');
        var toolbar = $('.cms-toolbar');

        this.ui = {
            container: container,
            content: $('.cms-structure-content'),
            doc: $(document),
            window: $(window),
            html: $('html'),
            toolbar: toolbar,
            sortables: $('.cms-draggables'), // global scope to include clipboard
            plugins: $('.cms-plugin'),
            render_model: $('.cms-render-model'),
            placeholders: $('.cms-placeholder'),
            dragitems: $('.cms-draggable'),
            dragareas: $('.cms-dragarea'),
            toolbarModeSwitcher: toolbar.find('.cms-toolbar-item-cms-mode-switcher'),
            toolbarModeLinks: toolbar.find('.cms-toolbar-item-cms-mode-switcher a')
        };

        this._preventScroll = new PreventParentScroll(this.ui.content[0]);
    }

    /**
     * Initial setup (and early bail if specific
     * elements do not exist).
     *
     * @method _setup
     * @private
     * @returns {Boolean|void}
     */
    _setup() {
        var that = this;

        // cancel if there is no structure / content switcher
        if (!this.ui.toolbarModeSwitcher.length) {
            return false;
        }

        // setup toolbar mode
        if (CMS.config.settings.mode === 'structure') {
            that.show({ init: true });
            that._loadedStructure = true;
            StructureBoard._initializeDragItemsStates();
        } else {
            // triggering hide here to switch proper classnames on switcher
            that.hide(true);
            that._loadedContent = true;
        }

        if (CMS.config.settings.legacy_mode) {
            that._loadedStructure = true;
            that._loadedContent = true;
        }

        // check if modes should be visible
        if (this.ui.dragareas.not('.cms-clipboard .cms-dragarea').length || this.ui.placeholders.length) {
            // eslint-disable-line
            this.ui.toolbarModeSwitcher.find('.cms-btn').removeClass('cms-btn-disabled');
        }

        // add drag & drop functionality
        // istanbul ignore next
        $('.cms-draggable').one(
            'pointerover.cms.drag',
            once(() => {
                $('.cms-draggable').off('pointerover.cms.drag');
                this._drag();
            })
        );
    }

    _preloadOppositeMode() {
        if (CMS.config.settings.legacy_mode) {
            return;
        }
        const WAIT_BEFORE_PRELOADING = 2000;

        $(Helpers._getWindow()).one('load', () => {
            setTimeout(() => {
                if (this._loadedStructure) {
                    this._requestMode('content');
                } else {
                    this._requestMode('structure');
                }
            }, WAIT_BEFORE_PRELOADING);
        });
    }

    _events() {
        this.ui.window.on('resize.cms.structureboard', () => {
            if (!this._loadedContent) {
                return;
            }
            const width = this.ui.window[0].innerWidth;
            const BREAKPOINT = 1024;

            if (width > BREAKPOINT && !this.condensed) {
                this._makeCondensed();
            }

            if (width <= BREAKPOINT && this.condensed) {
                this._makeFullWidth();
            }
        });
    }

    /**
     * Sets up events handlers for switching
     * structureboard modes.
     *
     * @method _setupModeSwitcher
     * @private
     */
    _setupModeSwitcher() {
        const modes = this.ui.toolbarModeLinks;
        let cmdPressed;

        $(Helpers._getWindow())
            .on(this.keyUpAndDown, e => {
                if (
                    e.keyCode === KEYS.CMD_LEFT ||
                    e.keyCode === KEYS.CMD_RIGHT ||
                    e.keyCode === KEYS.CMD_FIREFOX ||
                    e.keyCode === KEYS.SHIFT ||
                    e.keyCode === KEYS.CTRL
                ) {
                    cmdPressed = true;
                }
                if (e.type === 'keyup') {
                    cmdPressed = false;
                }
            })
            .on('blur', () => {
                cmdPressed = false;
            });

        // show edit mode
        modes.on(this.click, e => {
            e.preventDefault();
            e.stopImmediatePropagation();

            if (modes.hasClass('cms-btn-disabled')) {
                return;
            }

            if (cmdPressed && e.type === 'click') {
                // control the behaviour when ctrl/cmd is pressed
                Helpers._getWindow().open(modes.attr('href'), '_blank');
                return;
            }

            if (CMS.settings.mode === 'edit') {
                this.show();
            } else {
                this.hide();
            }
        });

        // keyboard handling
        // only if there is a structure / content switcher
        if (
            this.ui.toolbarModeSwitcher.length &&
            !this.ui.toolbarModeSwitcher.find('.cms-btn').is('.cms-btn-disabled')
        ) {
            keyboard.setContext('cms');
            keyboard.bind('space', e => {
                e.preventDefault();
                this._toggleStructureBoard();
            });
            keyboard.bind('shift+space', e => {
                e.preventDefault();
                this._toggleStructureBoard({ useHoveredPlugin: true });
            });
        }
    }

    /**
     * @method _toggleStructureBoard
     * @private
     * @param {Object} [options] options
     * @param {Boolean} [options.useHoveredPlugin] should the plugin be taken into account
     */
    _toggleStructureBoard(options = {}) {
        var that = this;

        if (options.useHoveredPlugin && CMS.settings.mode !== 'structure') {
            that._showAndHighlightPlugin(options.successTimeout).then($.noop, $.noop);
        } else if (!options.useHoveredPlugin) {
            // eslint-disable-next-line no-lonely-if
            if (CMS.settings.mode === 'structure') {
                that.hide();
            } else if (CMS.settings.mode === 'edit') {
                /* istanbul ignore else */ that.show();
            }
        }
    }

    /**
     * Shows structureboard, scrolls into view and highlights hovered plugin.
     * Uses CMS.API.Tooltip because it already handles multiple plugins living on
     * the same DOM node.
     *
     * @method _showAndHighlightPlugin
     * @private
     * @returns {Promise}
     */
    // eslint-disable-next-line no-magic-numbers
    _showAndHighlightPlugin(successTimeout = 200, seeThrough = false) {
        // cancel show if live modus is active
        if (CMS.config.mode === 'live') {
            return Promise.resolve(false);
        }

        if (!CMS.API.Tooltip) {
            return Promise.resolve(false);
        }

        var tooltip = CMS.API.Tooltip.domElem;
        var HIGHLIGHT_TIMEOUT = 10;
        var DRAGGABLE_HEIGHT = 50; // it's not precisely 50, but it fits

        if (!tooltip.is(':visible')) {
            return Promise.resolve(false);
        }

        var pluginId = tooltip.data('plugin_id');

        return this.show({ saveState: false }).then(function() {
            var draggable = $('.cms-draggable-' + pluginId);
            var doc = $(document);
            var currentExpandmode = doc.data('expandmode');

            // expand necessary parents
            doc.data('expandmode', false);
            draggable
                .parents('.cms-draggable')
                .find('> .cms-dragitem-collapsable:not(".cms-dragitem-expanded") > .cms-dragitem-text')
                .each((i, el) => $(el).triggerHandler(Plugin.click));

            setTimeout(() => doc.data('expandmode', currentExpandmode));
            setTimeout(function() {
                var offsetParent = draggable.offsetParent();
                var position = draggable.position().top + offsetParent.scrollTop();

                draggable.offsetParent().scrollTop(position - window.innerHeight / 2 + DRAGGABLE_HEIGHT);

                Plugin._highlightPluginStructure(draggable.find('.cms-dragitem:first'), { successTimeout, seeThrough });
            }, HIGHLIGHT_TIMEOUT);
        });
    }

    /**
     * Shows the structureboard. (Structure mode)
     *
     * @method show
     * @public
     * @param {Boolean} init true if this is first initialization
     * @returns {Promise}
     */
    show({ init = false, saveState = true } = {}) {
        // cancel show if live modus is active
        if (CMS.config.mode === 'live') {
            return Promise.resolve(false);
        }

        // in order to get consistent positioning
        // of the toolbar we have to know if the page
        // had the scrollbar and if it had - we adjust
        // the toolbar positioning
        if (init) {
            var width = this.ui.toolbar.width();
            var scrollBarWidth = this.ui.window[0].innerWidth - width;

            if (!scrollBarWidth && init) {
                scrollBarWidth = measureScrollbar();
            }

            if (scrollBarWidth) {
                this.ui.toolbar.css('right', scrollBarWidth);
            }
        }
        // apply new settings
        CMS.settings.mode = 'structure';
        Helpers.setSettings(CMS.settings);

        if (!init && saveState) {
            this._saveStateInURL();
        }

        return this._loadStructure().then(this._showBoard.bind(this, init));
    }

    _loadStructure() {
        // case when structure mode is already loaded
        if (CMS.config.settings.mode === 'structure' || this._loadedStructure) {
            return Promise.resolve();
        }

        showLoader();
        return this
            ._requestMode('structure')
            .done(contentMarkup => {
                this._requeststructure = null;
                hideLoader();

                CMS.settings.states = Helpers.getSettings().states;

                var bodyRegex = /<body[\S\s]*?>([\S\s]*)<\/body>/gi;
                var body = $(bodyRegex.exec(contentMarkup)[1]);

                var structure = body.find('.cms-structure-content');
                var toolbar = body.find('.cms-toolbar');
                var scripts = body.filter(function() {
                    var elem = $(this);

                    return elem.is('[type="text/cms-template"]'); // cms scripts
                });
                const pluginIds = this.getIds(body.find('.cms-draggable'));
                const pluginDataSource = body.filter('script[data-cms]').toArray()
                    .map(script => script.textContent || '').join();
                const pluginData = StructureBoard._getPluginDataFromMarkup(
                    pluginDataSource,
                    pluginIds
                );

                Plugin._updateRegistry(pluginData.map(([, data]) => data));

                CMS.API.Toolbar._refreshMarkup(toolbar);

                $('body').append(scripts);
                $('.cms-structure-content').html(structure.html());
                triggerWindowResize();

                StructureBoard._initializeGlobalHandlers();
                StructureBoard.actualizePlaceholders();
                CMS._instances.forEach(instance => {
                    if (instance.options.type === 'placeholder') {
                        instance._setPlaceholder();
                    }
                });
                CMS._instances.forEach(instance => {
                    if (instance.options.type === 'plugin') {
                        instance._setPluginStructureEvents();
                        instance._collapsables();
                    }
                });

                this.ui.sortables = $('.cms-draggables');
                this._drag();
                StructureBoard._initializeDragItemsStates();

                this._loadedStructure = true;
            })
            .fail(function() {
                window.location.href = new URI(window.location.href)
                    .addSearch(CMS.config.settings.structure)
                    .toString();
            });
    }

    _requestMode(mode) {
        var url = new URI(window.location.href);

        if (mode === 'structure') {
            url.addSearch(CMS.config.settings.structure);
        } else {
            url.addSearch(CMS.settings.edit || 'edit').removeSearch(CMS.config.settings.structure);
        }

        if (!this[`_request${mode}`]) {
            this[`_request${mode}`] = $.ajax({
                url: url.toString(),
                method: 'GET'
            }).then(markup => {
                preloadImagesFromMarkup(markup);

                return markup;
            });
        }

        return this[`_request${mode}`];
    }

    _loadContent() {
        var that = this;

        // case when content mode is already loaded
        if (CMS.config.settings.mode === 'edit' || this._loadedContent) {
            return Promise.resolve();
        }

        showLoader();
        return that
            ._requestMode('content')
            .done(function(contentMarkup) {
                that._requestcontent = null;
                hideLoader();
                var htmlRegex = /<html([\S\s]*?)>[\S\s]*<\/html>/gi;
                var bodyRegex = /<body([\S\s]*?)>([\S\s]*)<\/body>/gi;
                var headRegex = /<head[\S\s]*?>([\S\s]*)<\/head>/gi;
                var matches = bodyRegex.exec(contentMarkup);
                // we don't handle cases where body or html doesn't exist, cause it's highly unlikely
                // and will result in way more troubles for cms than this
                var bodyAttrs = matches[1];
                var body = $(matches[2]);
                var head = $(headRegex.exec(contentMarkup)[1]);
                var htmlAttrs = htmlRegex.exec(contentMarkup)[1];
                var bodyAttributes = $('<div ' + bodyAttrs + '></div>')[0].attributes;
                var htmlAttributes = $('<div ' + htmlAttrs + '></div>')[0].attributes;
                var newToolbar = body.find('.cms-toolbar');
                var toolbar = $('.cms').add('[data-cms]').detach();
                var title = head.filter('title');
                var bodyElement = $('body');

                // istanbul ignore else
                if (title) {
                    document.title = title.text();
                }

                body = body.filter(function() {
                    var elem = $(this);

                    return (
                        !elem.is('.cms#cms-top') && !elem.is('[data-cms]:not([data-cms-generic])') // toolbar
                    ); // cms scripts
                });
                body.find('[data-cms]:not([data-cms-generic])').remove(); // cms scripts

                [].slice.call(bodyAttributes).forEach(function(attr) {
                    bodyElement.attr(attr.name, attr.value);
                });

                [].slice.call(htmlAttributes).forEach(function(attr) {
                    $('html').attr(attr.name, attr.value);
                });

                bodyElement.append(body);
                $('head').append(head);
                bodyElement.prepend(toolbar);

                CMS.API.Toolbar._refreshMarkup(newToolbar);
                $(window).trigger('resize');

                Plugin._refreshPlugins();

                const scripts = $('script');

                // istanbul ignore next
                scripts.on('load', function() {
                    window.dispatchEvent(new Event('load'));
                    window.dispatchEvent(new Event('DOMContentLoaded'));
                });

                const unhandledPlugins = $('body').find('template.cms-plugin');

                if (unhandledPlugins.length) {
                    CMS.API.Messages.open({
                        message: CMS.config.lang.unhandledPageChange
                    });
                    Helpers.reloadBrowser();
                }

                that._loadedContent = true;
            })
            .fail(function() {
                window.location.href = new URI(window.location.href)
                    .removeSearch(CMS.config.settings.structure)
                    .toString();
            });
    }

    _saveStateInURL() {
        var url = new URI(window.location.href);

        url[CMS.settings.mode === 'structure' ? 'addSearch' : 'removeSearch'](CMS.config.settings.structure);

        history.replaceState({}, '', url.toString());
    }

    /**
     * Hides the structureboard. (Content mode)
     *
     * @method hide
     * @param {Boolean} init true if this is first initialization
     * @returns {Boolean|void}
     */
    hide(init) {
        // cancel show if live modus is active
        if (CMS.config.mode === 'live') {
            return false;
        }

        // reset toolbar positioning
        this.ui.toolbar.css('right', '');
        $('html').removeClass('cms-overflow');

        // set active item
        var modes = this.ui.toolbarModeLinks;

        modes.removeClass('cms-btn-active').eq(1).addClass('cms-btn-active');
        this.ui.html.removeClass('cms-structure-mode-structure').addClass('cms-structure-mode-content');

        CMS.settings.mode = 'edit';
        if (!init) {
            this._saveStateInURL();
        }

        // hide canvas
        return this._loadContent().then(this._hideBoard.bind(this));
    }

    /**
     * Gets the id of the element.
     * relies on cms-{item}-{id} to always be second in a string of classes (!)
     *
     * @method getId
     * @param {jQuery} el element to get id from
     * @returns {String}
     */
    getId(el) {
        // cancel if no element is defined
        if (el === undefined || el === null || el.length <= 0) {
            return false;
        }

        var id = null;
        var cls = el.attr('class').split(' ')[1];

        if (el.hasClass('cms-plugin')) {
            id = cls.replace('cms-plugin-', '').trim();
        } else if (el.hasClass('cms-draggable')) {
            id = cls.replace('cms-draggable-', '').trim();
        } else if (el.hasClass('cms-placeholder')) {
            id = cls.replace('cms-placeholder-', '').trim();
        } else if (el.hasClass('cms-dragbar')) {
            id = cls.replace('cms-dragbar-', '').trim();
        } else if (el.hasClass('cms-dragarea')) {
            id = cls.replace('cms-dragarea-', '').trim();
        }

        return id;
    }

    /**
     * Gets the ids of the list of  elements.
     *
     * @method getIds
     * @param {jQuery} els elements to get id from
     * @returns {String[]}
     */
    getIds(els) {
        var that = this;
        var array = [];

        els.each(function() {
            array.push(that.getId($(this)));
        });
        return array;
    }

    /**
     * Actually shows the board canvas.
     *
     * @method _showBoard
     * @param {Boolean} init init
     * @private
     */
    _showBoard(init) {
        // set active item
        var modes = this.ui.toolbarModeLinks;

        modes.removeClass('cms-btn-active').eq(0).addClass('cms-btn-active');
        this.ui.html.removeClass('cms-structure-mode-content').addClass('cms-structure-mode-structure');

        this.ui.container.show();
        hideLoader();

        if (!init) {
            this._makeCondensed();
        }

        if (init && !this._loadedContent) {
            this._makeFullWidth();
        }

        this._preventScroll.start();
        this.ui.window.trigger('resize');
    }

    _makeCondensed() {
        this.condensed = true;
        this.ui.container.addClass('cms-structure-condensed');
        var url = new URI(window.location.href);

        url.removeSearch('structure');

        if (CMS.settings.mode === 'structure') {
            history.replaceState({}, '', url.toString());
        }

        var width = this.ui.toolbar.width();
        var scrollBarWidth = this.ui.window[0].innerWidth - width;

        if (!scrollBarWidth) {
            scrollBarWidth = measureScrollbar();
        }

        this.ui.html.removeClass('cms-overflow');

        if (scrollBarWidth) {
            // this.ui.toolbar.css('right', scrollBarWidth);
            this.ui.container.css('right', -scrollBarWidth);
        }
    }

    _makeFullWidth() {
        this.condensed = false;
        this.ui.container.removeClass('cms-structure-condensed');
        var url = new URI(window.location.href);

        url.addSearch('structure');

        if (CMS.settings.mode === 'structure') {
            history.replaceState({}, '', url.toString());
            this.ui.html.addClass('cms-overflow');
        }

        this.ui.container.css('right', 0);
    }

    /**
     * Hides the board canvas.
     *
     * @method _hideBoard
     * @private
     */
    _hideBoard() {
        // hide elements
        this.ui.container.hide();
        this._preventScroll.stop();

        // this is sometimes required for user-side scripts to
        // render dynamic elements on the page correctly.
        // e.g. you have a parallax script that calculates position
        // of elements based on document height. but if the page is
        // loaded with structureboard active - the document height
        // would be same as screen height, which is likely incorrect,
        // so triggering resize on window would force user scripts
        // to recalculate whatever is required there
        // istanbul ignore catch
        triggerWindowResize();
    }

    /**
     * Sets up all the sortables.
     *
     * @method _drag
     * @param {jQuery} [elem=this.ui.sortables] which element to initialize
     * @private
     */
    _drag(elem = this.ui.sortables) {
        var that = this;

        elem
            .nestedSortable({
                items: '> .cms-draggable:not(.cms-draggable-disabled .cms-draggable)',
                placeholder: 'cms-droppable',
                connectWith: '.cms-draggables:not(.cms-hidden)',
                tolerance: 'intersect',
                toleranceElement: '> div',
                dropOnEmpty: true,
                // cloning huge structure is a performance loss compared to cloning just a dragitem
                helper: function createHelper(e, item) {
                    var clone = item.find('> .cms-dragitem').clone();

                    clone.wrap('<div class="' + item[0].className + '"></div>');
                    return clone.parent();
                },
                appendTo: '.cms-structure-content',
                // appendTo: '.cms',
                cursor: 'move',
                cursorAt: { left: -15, top: -15 },
                opacity: 1,
                zIndex: 9999999,
                delay: 100,
                tabSize: 15,
                // nestedSortable
                listType: 'div.cms-draggables',
                doNotClear: true,
                disableNestingClass: 'cms-draggable-disabled',
                errorClass: 'cms-draggable-disallowed',
                scrollSpeed: 15,
                // eslint-disable-next-line no-magic-numbers
                scrollSensitivity: that.ui.window.height() * 0.2,
                start: function(e, ui) {
                    that.ui.content.attr('data-touch-action', 'none');

                    originalPluginContainer = ui.item.closest('.cms-draggables');

                    that.dragging = true;
                    // show empty
                    StructureBoard.actualizePlaceholders();
                    // ensure all menus are closed
                    Plugin._hideSettingsMenu();
                    // keep in mind that caching cms-draggables query only works
                    // as long as we don't create them on the fly
                    that.ui.sortables.each(function() {
                        var element = $(this);

                        if (element.children().length === 0) {
                            element.removeClass('cms-hidden');
                        }
                    });

                    // fixes placeholder height
                    ui.item.addClass('cms-is-dragging');
                    ui.helper.addClass('cms-draggable-is-dragging');
                    if (ui.item.find('> .cms-draggables').children().length) {
                        ui.helper.addClass('cms-draggable-stack');
                    }

                    // attach escape event to cancel dragging
                    that.ui.doc.on('keyup.cms.interrupt', function(event, cancel) {
                        if ((event.keyCode === KEYS.ESC && that.dragging) || cancel) {
                            that.state = false;
                            $.ui.sortable.prototype._mouseStop();
                            that.ui.sortables.trigger('mouseup');
                        }
                    });
                },

                beforeStop: function(event, ui) {
                    that.dragging = false;
                    ui.item.removeClass('cms-is-dragging cms-draggable-stack');
                    that.ui.doc.off('keyup.cms.interrupt');
                    that.ui.content.attr('data-touch-action', 'pan-y');
                },

                update: function(event, ui) {
                    // cancel if isAllowed returns false
                    if (!that.state) {
                        return false;
                    }

                    var newPluginContainer = ui.item.closest('.cms-draggables');

                    if (originalPluginContainer.is(newPluginContainer)) {
                        // if we moved inside same container,
                        // but event is fired on a parent, discard update
                        if (!newPluginContainer.is(this)) {
                            return false;
                        }
                    } else {
                        StructureBoard.actualizePluginsCollapsibleStatus(
                            newPluginContainer.add(originalPluginContainer)
                        );
                    }

                    // we pass the id to the updater which checks within the backend the correct place
                    var id = that.getId(ui.item);
                    var plugin = $(`.cms-draggable-${id}`);
                    var eventData = {
                        id: id
                    };
                    var previousParentPlugin = originalPluginContainer.closest('.cms-draggable');

                    if (previousParentPlugin.length) {
                        var previousParentPluginId = that.getId(previousParentPlugin);

                        eventData.previousParentPluginId = previousParentPluginId;
                    }

                    // check if we copy/paste a plugin or not
                    if (originalPluginContainer.hasClass('cms-clipboard-containers')) {
                        originalPluginContainer.html(plugin.eq(0).clone(true, true));
                        Plugin._updateClipboard();
                        plugin.trigger('cms-paste-plugin-update', [eventData]);
                    } else {
                        plugin.trigger('cms-plugins-update', [eventData]);
                    }

                    // reset placeholder without entries
                    that.ui.sortables.each(function() {
                        var element = $(this);

                        if (element.children().length === 0) {
                            element.addClass('cms-hidden');
                        }
                    });

                    StructureBoard.actualizePlaceholders();
                },
                // eslint-disable-next-line complexity
                isAllowed: function(placeholder, placeholderParent, originalItem) {
                    // cancel if action is executed
                    if (CMS.API.locked) {
                        return false;
                    }
                    // getting restriction array
                    var bounds = [];
                    var immediateParentType;

                    if (placeholder && placeholder.closest('.cms-clipboard-containers').length) {
                        return false;
                    }

                    // if parent has class disabled, dissalow drop
                    if (placeholder && placeholder.parent().hasClass('cms-draggable-disabled')) {
                        return false;
                    }

                    var originalItemId = that.getId(originalItem);
                    // save original state events
                    var original = $('.cms-draggable-' + originalItemId);

                    // cancel if item has no settings
                    if (original.length === 0 || !original.data('cms')) {
                        return false;
                    }
                    var originalItemData = original.data('cms');
                    var parent_bounds = $.grep(originalItemData.plugin_parent_restriction, function(r) {
                        // special case when PlaceholderPlugin has a parent restriction named "0"
                        return r !== '0';
                    });
                    var type = originalItemData.plugin_type;
                    // prepare variables for bound
                    var holderId = that.getId(placeholder.closest('.cms-dragarea'));
                    var holder = $('.cms-placeholder-' + holderId);
                    var plugin;

                    if (placeholderParent && placeholderParent.length) {
                        // placeholderParent is always latest, it maybe that
                        // isAllowed is called _before_ placeholder is moved to a child plugin
                        plugin = $('.cms-draggable-' + that.getId(placeholderParent.closest('.cms-draggable')));
                    } else {
                        plugin = $('.cms-draggable-' + that.getId(placeholder.closest('.cms-draggable')));
                    }

                    // now set the correct bounds
                    // istanbul ignore else
                    if (holder.length) {
                        bounds = holder.data('cms').plugin_restriction;
                        immediateParentType = holder.data('cms').plugin_type;
                    }
                    if (plugin.length) {
                        bounds = plugin.data('cms').plugin_restriction;
                        immediateParentType = plugin.data('cms').plugin_type;
                    }

                    // if restrictions is still empty, proceed
                    that.state = !(bounds.length && $.inArray(type, bounds) === -1);

                    // check if we have a parent restriction
                    if (parent_bounds.length) {
                        that.state = $.inArray(immediateParentType, parent_bounds) !== -1;
                    }

                    return that.state;
                }
            })
            .on('cms-structure-update', StructureBoard.actualizePlaceholders);
    }

    _dragRefresh() {
        this.ui.sortables.each((i, el) => {
            const element = $(el);

            if (element.data('mjsNestedSortable')) {
                return;
            }

            this._drag(element);
        });
    }

    /**
     * @method invalidateState
     * @param {String} action - action to handle
     * @param {Object} data - data required to handle the object
     * @param {Object} opts
     * @param {Boolean} [opts.propagate=true] - should we propagate the change to other tabs or not
     */
    // eslint-disable-next-line complexity
    invalidateState(action, data, { propagate = true } = {}) {
        // eslint-disable-next-line default-case
        switch (action) {
            case 'COPY': {
                this.handleCopyPlugin(data);
                break;
            }

            case 'ADD': {
                this.handleAddPlugin(data);
                break;
            }

            case 'EDIT': {
                this.handleEditPlugin(data);
                break;
            }

            case 'DELETE': {
                this.handleDeletePlugin(data);
                break;
            }

            case 'CLEAR_PLACEHOLDER': {
                this.handleClearPlaceholder(data);
                break;
            }

            case 'PASTE':
            case 'MOVE': {
                this.handleMovePlugin(data);
                break;
            }

            case 'CUT': {
                this.handleCutPlugin(data);
                break;
            }
        }

        if (!action) {
            CMS.API.Helpers.reloadBrowser();
            return;
        }

        if (propagate) {
            this._propagateInvalidatedState(action, data);
        }

        // refresh content mode if needed
        // refresh toolbar
        var currentMode = CMS.settings.mode;

        if (currentMode === 'structure') {
            this._requestcontent = null;

            if (this._loadedContent && action !== 'COPY') {
                this.updateContent();
                return;  // Toolbar loaded
            }
        } else if (action !== 'COPY') {
            this._requestcontent = null;
            this.updateContent();
            return;  // Toolbar loaded

        }
        this._loadToolbar()
            .done(newToolbar => {
                CMS.API.Toolbar._refreshMarkup($(newToolbar).find('.cms-toolbar'));
            })
            .fail(() => Helpers.reloadBrowser());
    }

    _propagateInvalidatedState(action, data) {
        this.latestAction = [action, data];

        ls.set(storageKey, JSON.stringify([action, data, window.location.pathname]));
    }

    _listenToExternalUpdates() {
        if (!Helpers._isStorageSupported) {
            return;
        }

        ls.on(storageKey, this._handleExternalUpdate.bind(this));
    }

    _handleExternalUpdate(value) {
        // means localstorage was cleared while this page was open
        if (!value) {
            return;
        }

        const [action, data, pathname] = JSON.parse(value);

        if (pathname !== window.location.pathname) {
            return;
        }

        if (isEqual([action, data], this.latestAction)) {
            return;
        }

        this.invalidateState(action, data, { propagate: false });
    }

    updateContent() {
        const loader = $('<div class="cms-content-reloading"></div>');

        $('.cms-structure').before(loader);

        return this._requestMode('content')
            .done(markup => {
                // eslint-disable-next-line no-magic-numbers
                loader.fadeOut(100, () => loader.remove());
                this.refreshContent(markup);
            })
            .fail(() => loader.remove() && Helpers.reloadBrowser());
    }

    _loadToolbar() {
        const placeholderIds = getPlaceholderIds(CMS._plugins).map(id => `placeholders[]=${id}`).join('&');

        return $.ajax({
            url: Helpers.updateUrlWithPath(
                `${CMS.config.request.toolbar}?` +
                    placeholderIds +
                    '&' +
                    `obj_id=${CMS.config.request.pk}&` +
                    `obj_type=${encodeURIComponent(CMS.config.request.model)}`
            )
        });
    }

    // i think this should probably be a separate class at this point that handles all the reloading
    // stuff, it's a bit too much
    // eslint-disable-next-line complexity
    handleMovePlugin(data) {
        if (data.plugin_parent) {
            if (data.plugin_id) {
                const draggable = $(`.cms-draggable-${data.plugin_id}:last`);

                if (
                    !draggable.closest(`.cms-draggable-${data.plugin_parent}`).length &&
                    !draggable.is('.cms-draggable-from-clipboard')
                ) {
                    draggable.remove();
                }
            }

            // empty the children first because replaceWith takes too much time
            // when it's trying to remove all the data and event handlers from potentially big tree of plugins
            $(`.cms-draggable-${data.plugin_parent}`).html('').replaceWith(data.html);
        } else {
            // the one in the clipboard is first, so we need to take the second one,
            // that is already visually moved into correct place
            let draggable = $(`.cms-draggable-${data.plugin_id}:last`);

            // external update, have to move the draggable to correct place first
            if (!draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
                const pluginOrder = data.plugin_order;
                const index = findIndex(
                    pluginOrder,
                    pluginId => Number(pluginId) === Number(data.plugin_id) || pluginId === '__COPY__'
                );
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);

                if (draggable.is('.cms-draggable-from-clipboard')) {
                    draggable = draggable.clone();
                }

                if (index === 0) {
                    placeholderDraggables.prepend(draggable);
                } else if (index !== -1) {
                    placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
                }
            }

            // if we _are_ in the correct placeholder we still need to check if the order is correct
            // since it could be an external update of a plugin moved in the same placeholder. also we are top-level
            if (draggable.closest('.cms-draggables').parent().is(`.cms-dragarea-${data.placeholder_id}`)) {
                const placeholderDraggables = $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`);
                const actualPluginOrder = this.getIds(
                    placeholderDraggables.find('> .cms-draggable')
                );

                if (!arrayEquals(actualPluginOrder, data.plugin_order)) {
                    // so the plugin order is not correct, means it's an external update and we need to move
                    const pluginOrder = data.plugin_order;
                    const index = findIndex(
                        pluginOrder,
                        pluginId => Number(pluginId) === Number(data.plugin_id)
                    );

                    if (index === 0) {
                        placeholderDraggables.prepend(draggable);
                    } else if (index !== -1) {
                        placeholderDraggables.find(`.cms-draggable-${pluginOrder[index - 1]}`).after(draggable);
                    }
                }
            }

            if (draggable.length) {
                // empty the children first because replaceWith takes too much time
                // when it's trying to remove all the data and event handlers from potentially big tree of plugins
                draggable.html('').replaceWith(data.html);
            } else if (data.target_placeholder_id) {
                // copy from language
                $(`.cms-dragarea-${data.target_placeholder_id} > .cms-draggables`).append(data.html);
            }
        }

        StructureBoard.actualizePlaceholders();
        Plugin._updateRegistry(data.plugins);
        data.plugins.forEach(pluginData => {
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
        });

        StructureBoard._initializeDragItemsStates();

        this.ui.sortables = $('.cms-draggables');
        this._dragRefresh();
    }

    handleCopyPlugin(data) {
        if (CMS.API.Clipboard._isClipboardModalOpen()) {
            CMS.API.Clipboard.modal.close();
        }

        $('.cms-clipboard-containers').html(data.html);
        const cloneClipboard = $('.cms-clipboard').clone();

        $('.cms-clipboard').replaceWith(cloneClipboard);

        const pluginData = [`cms-plugin-${data.plugins[0].plugin_id}`, data.plugins[0]];

        Plugin.aliasPluginDuplicatesMap[pluginData[1].plugin_id] = false;
        CMS._plugins.push(pluginData);
        CMS._instances.push(new Plugin(pluginData[0], pluginData[1]));

        CMS.API.Clipboard = new Clipboard();

        Plugin._updateClipboard();

        let html = '';

        const clipboardDraggable = $('.cms-clipboard .cms-draggable:first');

        html = clipboardDraggable.parent().html();

        CMS.API.Clipboard.populate(html, pluginData[1]);
        CMS.API.Clipboard._enableTriggers();

        this.ui.sortables = $('.cms-draggables');
        this._dragRefresh();
    }

    handleCutPlugin(data) {
        this.handleDeletePlugin(data);
        this.handleCopyPlugin(data);
    }

    _extractMessages(doc) {
        let messageList = doc.find('.messagelist');
        let messages = messageList.find('li');

        if (!messageList.length || !messages.length) {
            messageList = doc.find('[data-cms-messages-container]');
            messages = messageList.find('[data-cms-message]');
        }

        if (messages.length) {
            messageList.remove();

            return compact(
                messages.toArray().map(el => {
                    const msgEl = $(el);
                    const message = $(el).text().trim();

                    if (message) {
                        return {
                            message,
                            error: msgEl.data('cms-message-tags') === 'error' || msgEl.hasClass('error')
                        };
                    }
                })
            );
        }

        return [];
    }

    refreshContent(contentMarkup) {
        this._requestcontent = null;
        if (!this._loadedStructure) {
            this._requeststructure = null;
        }
        var fixedContentMarkup = contentMarkup;
        var newDoc = new DOMParser().parseFromString(fixedContentMarkup, 'text/html');
        let newScripts = $(newDoc).find('script');
        let oldScripts = $(document).find('script');

        const structureScrollTop = $('.cms-structure-content').scrollTop();

        var toolbar = $('#cms-top, [data-cms]').detach();
        var newToolbar = $(newDoc).find('.cms-toolbar').clone();

        $(newDoc).find('#cms-top, [data-cms]').remove();

        const messages = this._extractMessages($(newDoc));

        if (messages.length) {
            setTimeout(() =>
                messages.forEach(message => {
                    CMS.API.Messages.open(message);
                })
            );
        }

        var headDiff = dd.diff(document.head, newDoc.head);

        StructureBoard._replaceBodyWithHTML(newDoc.body.innerHTML);
        dd.apply(document.head, headDiff);
        toolbar.prependTo(document.body);
        CMS.API.Toolbar._refreshMarkup(newToolbar);

        this.addJsScriptsNeededForRender(newScripts, oldScripts);

        $('.cms-structure-content').scrollTop(structureScrollTop);
        Plugin._refreshPlugins();
        $(Helpers._getWindow()).trigger('cms-content-refresh');

        this._loadedContent = true;
    }

    /**
     * Checks if new scripts with the class 'cms-execute-js-to-render' exist
     * and if they were present before. If they weren't present before - they will be downloaded
     * and executed. If the script also has the class 'cms-trigger-load-events' the
     * 'load' and 'DOMContentLoaded' events will be triggered
     *
     * @param {jQuery} newScripts  jQuery selector of the scripts for the new body content
     * @param {jQuery} oldScripts  jQuery selector of the scripts for the old body content
     */
    addJsScriptsNeededForRender(newScripts, oldScripts) {
        const scriptSrcList = [];
        let classListCollection = [];
        const that = this;

        newScripts.each(function() {
            let scriptExists = false;
            let newScript = $(this);

            if (newScript.hasClass('cms-execute-js-to-render')) {
                $(oldScripts).each(function() {
                    let oldScript = $(this);

                    if (newScript.prop('outerHTML') === oldScript.prop('outerHTML')) {
                        scriptExists = true;
                        return false;
                    }
                });
                if (!scriptExists) {
                    let classList = newScript.attr('class').split(' ');

                    classListCollection = classListCollection.concat(classList);
                    if (typeof newScript.prop('src') === 'string' && newScript.prop('src') !== '') {
                        scriptSrcList.push(newScript.prop('src'));
                    } else {
                        let jsFile = document.createElement('script');

                        jsFile.textContent = newScript.prop('textContent') || '';
                        jsFile.type = 'text/javascript';
                        document.body.appendChild(jsFile);
                    }
                }
            }
        });
        if (scriptSrcList.length === 0) {
            that.triggerLoadEventsByClass(classListCollection);
        } else {
            Promise.all(scriptSrcList.map(s => $.getScript(s))).then(function() {
                that.triggerLoadEventsByClass(classListCollection);
            });
        }
    }

    /**
     * Triggers events if specific classes were in any of the scripts added by
     * the method addJsScriptsNeededForRender
     *
     * @param {String[]} classListCollection  array of all classes the script tags had
     */
    triggerLoadEventsByClass(classListCollection) {
        if (classListCollection.indexOf('cms-trigger-event-document-DOMContentLoaded') > -1) {
            Helpers._getWindow().document.dispatchEvent(new Event('DOMContentLoaded'));
        }
        if (classListCollection.indexOf('cms-trigger-event-window-DOMContentLoaded') > -1) {
            Helpers._getWindow().dispatchEvent(new Event('DOMContentLoaded'));
        }
        if (classListCollection.indexOf('cms-trigger-event-window-load') > -1) {
            Helpers._getWindow().dispatchEvent(new Event('load'));
        }
    }

    handleAddPlugin(data) {
        if (data.plugin_parent) {
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
        } else {
            // the one in the clipboard is first
            $(`.cms-dragarea-${data.placeholder_id} > .cms-draggables`).append(data.structure.html);
        }

        StructureBoard.actualizePlaceholders();
        Plugin._updateRegistry(data.structure.plugins);
        data.structure.plugins.forEach(pluginData => {
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
        });

        this.ui.sortables = $('.cms-draggables');
        this._dragRefresh();
    }

    handleEditPlugin(data) {
        if (data.plugin_parent) {
            $(`.cms-draggable-${data.plugin_parent}`).replaceWith(data.structure.html);
        } else {
            $(`.cms-draggable-${data.plugin_id}`).replaceWith(data.structure.html);
        }

        Plugin._updateRegistry(data.structure.plugins);

        data.structure.plugins.forEach(pluginData => {
            StructureBoard.actualizePluginCollapseStatus(pluginData.plugin_id);
        });

        this.ui.sortables = $('.cms-draggables');
        this._dragRefresh();
    }

    handleDeletePlugin(data) {
        var deletedPluginIds = [data.plugin_id];
        var draggable = $('.cms-draggable-' + data.plugin_id);
        var children = draggable.find('.cms-draggable');
        let parent = draggable.parent().closest('.cms-draggable');

        if (!parent.length) {
            parent = draggable.closest('.cms-dragarea');
        }

        if (children.length) {
            deletedPluginIds = deletedPluginIds.concat(this.getIds(children));
        }

        draggable.remove();

        StructureBoard.actualizePluginsCollapsibleStatus(parent.find('> .cms-draggables'));
        StructureBoard.actualizePlaceholders();
        deletedPluginIds.forEach(function(pluginId) {
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${pluginId}`);
            remove(
                CMS._instances,
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(pluginId)
            );
        });
    }

    handleClearPlaceholder(data) {
        var deletedIds = CMS._instances
            .filter(instance => {
                if (
                    instance.options.plugin_id &&
                    Number(instance.options.placeholder_id) === Number(data.placeholder_id)
                ) {
                    return true;
                }
            })
            .map(instance => instance.options.plugin_id);

        deletedIds.forEach(id => {
            remove(CMS._plugins, settings => settings[0] === `cms-plugin-${id}`);
            remove(
                CMS._instances,
                instance => instance.options.plugin_id && Number(instance.options.plugin_id) === Number(id)
            );

            $(`.cms-draggable-${id}`).remove();
        });

        StructureBoard.actualizePlaceholders();
    }

    /**
     * Similar to CMS.Plugin populates globally required
     * variables, that only need querying once, e.g. placeholders.
     *
     * @method _initializeGlobalHandlers
     * @static
     * @private
     */
    static _initializeGlobalHandlers() {
        placeholders = $('.cms-dragarea:not(.cms-clipboard-containers)');
    }

    /**
     * Checks if placeholders are empty and enables/disables certain actions on them, hides or shows the
     * "empty placeholder" placeholder and adapts the location of "Plugin will be added here" placeholder
     *
     * @function actualizePlaceholders
     * @private
     */
    static actualizePlaceholders() {
        placeholders.each(function() {
            var placeholder = $(this);
            var copyAll = placeholder.find('.cms-dragbar .cms-submenu-item:has(a[data-rel="copy"]):first');

            if (
                placeholder.find('> .cms-draggables').children('.cms-draggable').not('.cms-draggable-is-dragging')
                    .length
            ) {
                placeholder.removeClass('cms-dragarea-empty');
                copyAll.removeClass('cms-submenu-item-disabled');
                copyAll.find('> a').removeAttr('aria-disabled');
            } else {
                placeholder.addClass('cms-dragarea-empty');
                copyAll.addClass('cms-submenu-item-disabled');
                copyAll.find('> a').attr('aria-disabled', 'true');
            }
        });

        const addPluginPlaceholder = $('.cms-dragarea .cms-add-plugin-placeholder');

        if (addPluginPlaceholder.length && !addPluginPlaceholder.is(':last')) {
            addPluginPlaceholder.appendTo(addPluginPlaceholder.parent());
        }
    }

    /**
     * actualizePluginCollapseStatus
     *
     * @public
     * @param {String} pluginId open the plugin if it should be open
     */
    static actualizePluginCollapseStatus(pluginId) {
        const el = $(`.cms-draggable-${pluginId}`);
        const open = find(CMS.settings.states, openPluginId => Number(openPluginId) === Number(pluginId));

        // only add this class to elements which have a draggable area
        // istanbul ignore else
        if (open && el.find('> .cms-draggables').length) {
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
        }
    }

    /**
     * @function actualizePluginsCollapsibleStatus
     * @private
     * @param {jQuery} els lists of plugins (.cms-draggables)
     */
    static actualizePluginsCollapsibleStatus(els) {
        els.each(function() {
            var childList = $(this);
            var pluginDragItem = childList.closest('.cms-draggable').find('> .cms-dragitem');

            if (childList.children().length) {
                pluginDragItem.addClass('cms-dragitem-collapsable');
                if (childList.children().is(':visible')) {
                    pluginDragItem.addClass('cms-dragitem-expanded');
                }
            } else {
                pluginDragItem.removeClass('cms-dragitem-collapsable');
            }
        });
    }

    static _replaceBodyWithHTML(html) {
        document.body.innerHTML = html;
    }

    highlightPluginFromUrl() {
        const hash = window.location.hash;
        const regex = /cms-plugin-(\d+)/;

        if (!hash || !hash.match(regex)) {
            return;
        }

        const pluginId = regex.exec(hash)[1];

        if (this._loadedContent) {
            Plugin._highlightPluginContent(pluginId, {
                seeThrough: true,
                prominent: true,
                delay: 3000
            });
        }
    }

    /**
     * Get's plugins data from markup
     *
     * @method _getPluginDataFromMarkup
     * @private
     * @param {String} markup
     * @param {Array<Number | String>} pluginIds
     * @returns {Array<[String, Object]>}
     */
    static _getPluginDataFromMarkup(markup, pluginIds) {
        return compact(
            pluginIds.map(pluginId => {
                // oh boy
                const regex = new RegExp(`CMS._plugins.push\\((\\["cms\-plugin\-${pluginId}",[\\s\\S]*?\\])\\)`, 'g');
                const matches = regex.exec(markup);
                let settings;

                if (matches) {
                    try {
                        settings = JSON.parse(matches[1]);
                    } catch (e) {
                        settings = false;
                    }
                } else {
                    settings = false;
                }

                return settings;
            })
        );
    }

}

/**
 * Initializes the collapsed/expanded states of dragitems in structureboard.
 *
 * @method _initializeDragItemsStates
 * @static
 * @private
 */
// istanbul ignore next
StructureBoard._initializeDragItemsStates = function _initializeDragItemsStates() {
    // removing duplicate entries
    var states = CMS.settings.states || [];
    var sortedArr = states.sort();
    var filteredArray = [];

    for (var i = 0; i < sortedArr.length; i++) {
        if (sortedArr[i] !== sortedArr[i + 1]) {
            filteredArray.push(sortedArr[i]);
        }
    }
    CMS.settings.states = filteredArray;

    // loop through the items
    $.each(CMS.settings.states, function(index, id) {
        var el = $('.cms-draggable-' + id);

        // only add this class to elements which have immediate children
        if (el.find('> .cms-collapsable-container > .cms-draggable').length) {
            el.find('> .cms-collapsable-container').removeClass('cms-hidden');
            el.find('> .cms-dragitem').addClass('cms-dragitem-expanded');
        }
    });
};

// shorthand for jQuery(document).ready();
$(StructureBoard._initializeGlobalHandlers);

export default StructureBoard;