• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divio / django-cms / #31220

29 Jun 2026 11:07AM UTC coverage: 90.171% (+14.4%) from 75.811%
#31220

push

travis-ci

web-flow
Merge 6768d2df3 into 5a98881c1

1399 of 2226 branches covered (62.85%)

9559 of 10601 relevant lines covered (90.17%)

11.1 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

58.62
/cms/static/cms/js/modules/cms.plugins.js
1
/*
2
 * Copyright https://github.com/divio/django-cms
3
 */
4
import Modal from './cms.modal';
5
import StructureBoard from './cms.structureboard';
6
import $ from 'jquery';
7
import nextUntil from './nextuntil';
8

9
import debounce from 'lodash-es/debounce.js';
10
import uniqWith from 'lodash-es/uniqWith.js';
11
import once from 'lodash-es/once.js';
12
import difference from 'lodash-es/difference.js';
13
import isEqual from 'lodash-es/isEqual.js';
14

15
import { Helpers, KEYS, $window, $document, uid } from './cms.base';
16
import { showLoader, hideLoader } from './loader';
17

18
var clipboardDraggable;
19
var path = window.location.pathname + window.location.search;
1✔
20

21
var pluginUsageMap = Helpers._isStorageSupported ? JSON.parse(localStorage.getItem('cms-plugin-usage') || '{}') : {};
1!
22

23
const isStructureReady = () =>
1✔
24
    CMS.config.settings.mode === 'structure' ||
×
25
    CMS.config.settings.legacy_mode ||
26
    CMS.API.StructureBoard._loadedStructure;
27
const isContentReady = () =>
1✔
28
    CMS.config.settings.mode !== 'structure' ||
×
29
    CMS.config.settings.legacy_mode ||
30
    CMS.API.StructureBoard._loadedContent;
31

32
/**
33
 * Class for handling Plugins / Placeholders or Generics.
34
 * Handles adding / moving / copying / pasting / menus etc
35
 * in structureboard.
36
 *
37
 * @class Plugin
38
 * @namespace CMS
39
 * @uses CMS.API.Helpers
40
 */
41
class Plugin {
42
    constructor(container, options) {
43
        // Copy Helpers methods to instance
44
        Object.assign(this, Helpers);
179✔
45

46
        this.options = $.extend(true, {}, {
179✔
47
            type: '', // bar, plugin or generic
48
            placeholder_id: null,
49
            plugin_type: '',
50
            plugin_id: null,
51
            plugin_parent: null,
52
            plugin_restriction: [],
53
            urls: {
54
                add_plugin: '',
55
                edit_plugin: '',
56
                move_plugin: '',
57
                copy_plugin: '',
58
                delete_plugin: ''
59
            }
60
        }, options);
61

62
        // create an unique for this component to use it internally
63
        this.uid = uid();
179✔
64

65
        // this property will be filled later
66
        this.modal = null;
179✔
67

68
        this._setupUI(container);
179✔
69
        this._ensureData();
179✔
70

71
        if (this.options.type === 'plugin' && Plugin.aliasPluginDuplicatesMap[this.options.plugin_id]) {
179✔
72
            return;
1✔
73
        }
74
        if (this.options.type === 'placeholder' && Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id]) {
178✔
75
            return;
1✔
76
        }
77

78
        // determine type of plugin
79
        switch (this.options.type) {
177✔
80
            case 'placeholder': // handler for placeholder bars
81
                Plugin.staticPlaceholderDuplicatesMap[this.options.placeholder_id] = true;
23✔
82
                this.ui.container.data('cms', this.options);
23✔
83
                this._setPlaceholder();
23✔
84
                if (isStructureReady()) {
23!
85
                    this._collapsables();
23✔
86
                }
87
                break;
23✔
88
            case 'plugin': // handler for all plugins
89
                this.ui.container.data('cms').push(this.options);
130✔
90
                Plugin.aliasPluginDuplicatesMap[this.options.plugin_id] = true;
130✔
91
                this._setPlugin();
130✔
92
                if (isStructureReady()) {
130!
93
                    this._collapsables();
130✔
94
                }
95
                break;
130✔
96
            default:
97
                // handler for static content
98
                this.ui.container.data('cms').push(this.options);
24✔
99
                this._setGeneric();
24✔
100
        }
101
    }
102

103
    _ensureData() {
104
        // bind data element to the container (mutating!)
105
        if (!this.ui.container.data('cms')) {
179✔
106
            this.ui.container.data('cms', []);
174✔
107
        }
108
    }
109

110
    /**
111
     * Caches some jQuery references and sets up structure for
112
     * further initialisation.
113
     *
114
     * @method _setupUI
115
     * @private
116
     * @param {String} container `cms-plugin-${id}`
117
     */
118
    _setupUI(container) {
119
        const wrapper = $(`.${container}`);
179✔
120
        let contents;
121

122
        // have to check for cms-plugin, there can be a case when there are multiple
123
        // static placeholders or plugins rendered twice, there could be multiple wrappers on same page
124
        if (wrapper.length > 1 && container.match(/cms-plugin/)) {
179✔
125
            // Get array [[start, end], [start, end], ...]
126
            const contentWrappers = this._extractContentWrappers(wrapper);
136✔
127

128
            if (contentWrappers[0][0].tagName === 'TEMPLATE') {
136!
129
                // then - if the content is bracketed by two template tages - we map that structure into an array of
130
                // jquery collections from which we filter out empty ones
131
                contents = contentWrappers
136✔
132
                    .map(items => this._processTemplateGroup(items, container))
137✔
133
                    .filter(v => v.length);
137✔
134

135
                wrapper.filter('template').remove();
136✔
136
                if (contents.length) {
136!
137
                    // and then reduce it to one big collection
138
                    contents = contents.reduce((collection, items) => collection.add(items), $());
137✔
139
                }
140
            } else {
141
                contents = wrapper;
×
142
            }
143
        } else {
144
            contents = wrapper;
43✔
145
        }
146

147
        // in clipboard can be non-existent
148
        if (!contents.length) {
179✔
149
            contents = $('<div></div>');
11✔
150
        }
151

152
        this.ui = this.ui || {};
179✔
153
        this.ui.container = contents;
179✔
154
    }
155

156
    /**
157
     * Extracts the content wrappers from the given wrapper:
158
     * It is possible that multiple plugins (more often generics) are rendered
159
     * in different places. e.g. page menu in the header and in the footer
160
     * so first, we find all the template tags, then put them in a structure like this:
161
     * [[start, end], [start, end], ...]
162
     *
163
     * @method _extractContentWrappers
164
     * @private
165
     * @param {jQuery} wrapper
166
     * @returns {Array<Array<HTMLElement>>}
167
     */
168
    _extractContentWrappers(wrapper) {
169
        return wrapper.toArray().reduce((wrappers, elem) => {
136✔
170
            if (elem.classList.contains('cms-plugin-start') || wrappers.length === 0) {
274✔
171
                wrappers.push([elem]);
137✔
172
            } else {
173
                wrappers.at(-1).push(elem);
137✔
174
            }
175
            return wrappers;
274✔
176
        }, []);
177
    }
178

179
    /**
180
     * Processes the template group and returns a jQuery collection
181
     * of the content bracketed by ``cms-plugin-start`` and ``cms-plugin-end``.
182
     * It also wraps any top-level text nodes in ``cms-plugin`` elements.
183
     *
184
     * @method _processTemplateGroup
185
     * @private
186
     * @param {Array<HTMLElement>} items
187
     * @param {HTMLElement} container
188
     * @returns {jQuery}
189
     * @example
190
     * // Given the following HTML:
191
     * <template class="cms-plugin cms-plugin-4711 cms-plugin-start"></template>
192
     * <p>Some text</p>
193
     * <template class="cms-plugin cms-plugin-4711 cms-plugin-end"></template>
194
     *
195
     * // The following jQuery collection will be returned:
196
     * $('<p class="cms-plugin cms-plugin-4711 cms-plugin-start cms-plugin-end">Some text</p>')
197
     */
198
    _processTemplateGroup(items, container) {
199
        const templateStart = $(items[0]);
137✔
200
        const className = templateStart.attr('class').replace('cms-plugin-start', '');
137✔
201
        let itemContents = $(nextUntil(templateStart[0], container));
137✔
202

203
        itemContents.each((index, el) => {
137✔
204
            if (el.nodeType === Node.TEXT_NODE && !el.textContent.match(/^\s*$/)) {
158✔
205
                const element = $(el);
10✔
206

207
                element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
10✔
208
                itemContents[index] = element.parent()[0];
10✔
209
            }
210
        });
211

212
        itemContents = itemContents.filter(function() {
137✔
213
            return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
158✔
214
        });
215

216
        itemContents.addClass(`cms-plugin ${className}`);
137✔
217
        itemContents.first().addClass('cms-plugin-start');
137✔
218
        itemContents.last().addClass('cms-plugin-end');
137✔
219

220
        return itemContents;
137✔
221
    }
222

223
    /**
224
     * Sets up behaviours and ui for placeholder.
225
     *
226
     * @method _setPlaceholder
227
     * @private
228
     */
229
    _setPlaceholder() {
230
        var that = this;
23✔
231

232
        this.ui.dragbar = $('.cms-dragbar-' + this.options.placeholder_id);
23✔
233
        this.ui.draggables = this.ui.dragbar.closest('.cms-dragarea').find('> .cms-draggables');
23✔
234
        this.ui.submenu = this.ui.dragbar.find('.cms-submenu-settings');
23✔
235
        var title = this.ui.dragbar.find('.cms-dragbar-title');
23✔
236
        var togglerLinks = this.ui.dragbar.find('.cms-dragbar-toggler a');
23✔
237
        var expanded = 'cms-dragbar-title-expanded';
23✔
238

239
        // register the subnav on the placeholder
240
        this._setSettingsMenu(this.ui.submenu);
23✔
241
        this._setAddPluginModal(this.ui.dragbar.find('.cms-submenu-add'));
23✔
242

243
        // istanbul ignore next
244
        CMS.settings.dragbars = CMS.settings.dragbars || []; // expanded dragbars array
245

246
        // enable expanding/collapsing globally within the placeholder
247
        togglerLinks.off(Plugin.click).on(Plugin.click, function(e) {
23✔
248
            e.preventDefault();
×
249
            if (title.hasClass(expanded)) {
×
250
                that._collapseAll(title);
×
251
            } else {
252
                that._expandAll(title);
×
253
            }
254
        });
255

256
        if ($.inArray(this.options.placeholder_id, CMS.settings.dragbars) !== -1) {
23!
257
            title.addClass(expanded);
×
258
        }
259

260
        this._checkIfPasteAllowed();
23✔
261
    }
262

263
    /**
264
     * Sets up behaviours and ui for plugin.
265
     *
266
     * @method _setPlugin
267
     * @private
268
     */
269
    _setPlugin() {
270
        if (isStructureReady()) {
130!
271
            this._setPluginStructureEvents();
130✔
272
        }
273
        if (isContentReady()) {
130!
274
            this._setPluginContentEvents();
130✔
275
        }
276
    }
277

278
    _setPluginStructureEvents() {
279
        var that = this;
130✔
280

281
        // filling up ui object
282
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
130✔
283
        this.ui.dragitem = this.ui.draggable.find('> .cms-dragitem');
130✔
284
        this.ui.draggables = this.ui.draggable.find('> .cms-draggables');
130✔
285
        this.ui.submenu = this.ui.dragitem.find('.cms-submenu');
130✔
286

287
        this.ui.draggable.data('cms', this.options);
130✔
288

289
        if (!this.ui.draggable.hasClass('cms-slot')) {
130!
290
            this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
130✔
291
        }
292

293
        // adds listener for all plugin updates
294
        this.ui.draggable.off('cms-plugins-update').on('cms-plugins-update', function(e, eventData) {
130✔
295
            e.stopPropagation();
×
296
            that.movePlugin(null, eventData);
×
297
        });
298

299
        // adds listener for copy/paste updates
300
        this.ui.draggable.off('cms-paste-plugin-update').on('cms-paste-plugin-update', function(e, eventData) {
130✔
301
            e.stopPropagation();
5✔
302

303
            var dragitem = $(`.cms-draggable-${eventData.id}:last`);
5✔
304

305
            // find out new placeholder id
306
            var placeholder_id = that._getId(dragitem.closest('.cms-dragarea'));
5✔
307

308
            // if placeholder_id is empty, cancel
309
            if (!placeholder_id) {
5!
310
                return false;
×
311
            }
312

313
            var data = dragitem.data('cms');
5✔
314

315
            data.target = placeholder_id;
5✔
316
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
5✔
317
            data.move_a_copy = true;
5✔
318

319
            // expand the plugin we paste to
320
            CMS.settings.states.push(data.parent);
5✔
321
            Helpers.setSettings(CMS.settings);
5✔
322

323
            that.movePlugin(data);
5✔
324
        });
325

326
        setTimeout(() => {
130✔
327
            this.ui.dragitem
130✔
328
                .on('mouseenter', e => {
329
                    e.stopPropagation();
×
330
                    if (!$document.data('expandmode')) {
×
331
                        return;
×
332
                    }
333
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
×
334
                        return;
×
335
                    }
336
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
337
                        return;
×
338
                    }
339
                    if (CMS.API.StructureBoard.dragging) {
×
340
                        return;
×
341
                    }
342

343
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
×
344
                })
345
                .on('mouseleave', e => {
346
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
347
                        return;
×
348
                    }
349
                    e.stopPropagation();
×
350

351
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
352
                });
353
            // attach event to the plugin menu
354
            this._setSettingsMenu(this.ui.submenu);
130✔
355

356
            // attach events for the "Add plugin" modal
357
            this._setAddPluginModal(this.ui.dragitem.find('.cms-submenu-add'));
130✔
358

359
            // clickability of "Paste" menu item
360
            this._checkIfPasteAllowed();
130✔
361
        });
362
    }
363

364
    _dblClickToEditHandler(e) {
365
        var that = this;
×
366
        var disabled = $(e.currentTarget).closest('.cms-drag-disabled');
×
367
        var edit_disabled = $(e.currentTarget).closest('.cms-draggable').hasClass('cms-slot');
×
368

369
        e.preventDefault();
×
370
        e.stopPropagation();
×
371

372
        if (!disabled.length && !edit_disabled) {
×
373
            that.editPlugin(
×
374
                Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
375
                that.options.plugin_name,
376
                that._getPluginBreadcrumbs()
377
            );
378
        }
379
    }
380

381
    _setPluginContentEvents() {
382
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
130✔
383

384
        this.ui.container
130✔
385
            .off('mouseover.cms.plugins')
386
            .on('mouseover.cms.plugins', e => {
387
                if (!$document.data('expandmode')) {
×
388
                    return;
×
389
                }
390
                if (CMS.settings.mode !== 'structure') {
×
391
                    return;
×
392
                }
393
                e.stopPropagation();
×
394
                $('.cms-dragitem-success').remove();
×
395
                $('.cms-draggable-success').removeClass('cms-draggable-success');
×
396
                CMS.API.StructureBoard._showAndHighlightPlugin(0, true);
×
397
            })
398
            .off('mouseout.cms.plugins')
399
            .on('mouseout.cms.plugins', e => {
400
                if (CMS.settings.mode !== 'structure') {
×
401
                    return;
×
402
                }
403
                e.stopPropagation();
×
404
                if (this.ui.draggable && this.ui.draggable.length) {
×
405
                    this.ui.draggable.find('.cms-dragitem-success').remove();
×
406
                    this.ui.draggable.removeClass('cms-draggable-success');
×
407
                }
408
                // Plugin._removeHighlightPluginContent(this.options.plugin_id);
409
            });
410

411
        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
130✔
412
            // only allow editing by double-click if not disabled
413
            var selector = `.cms-plugin-${this.options.plugin_id}:not(.cms-slot)`;
129✔
414

415
            $document
129✔
416
                .off(pluginDoubleClickEvent, selector)
417
                .on(
418
                    pluginDoubleClickEvent,
419
                    selector,
420
                    this._dblClickToEditHandler.bind(this)
421
                );
422
        }
423
    }
424

425
    /**
426
     * Sets up behaviours and ui for generics.
427
     * Generics do not show up in structure board.
428
     *
429
     * @method _setGeneric
430
     * @private
431
     */
432
    _setGeneric() {
433
        var that = this;
24✔
434

435
        // adds double click to edit
436
        this.ui.container.off(Plugin.doubleClick).on(Plugin.doubleClick, function(e) {
24✔
437
            e.preventDefault();
×
438
            e.stopPropagation();
×
439
            that.editPlugin(Helpers.updateUrlWithPath(that.options.urls.edit_plugin), that.options.plugin_name, []);
×
440
        });
441

442
        // adds edit tooltip
443
        this.ui.container
24✔
444
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
445
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
446
                if (e.type !== 'touchstart') {
×
447
                    e.stopPropagation();
×
448
                }
449
                var name = that.options.plugin_name;
×
450
                var id = that.options.plugin_id;
×
451
                var disabled = $(e.currentTarget).hasClass('cms-slot'); // No tooltip for disabled plugins
×
452

453
                CMS.API.Tooltip.displayToggle(
×
454
                    (e.type === 'pointerover' || e.type === 'touchstart') && !disabled,
×
455
                    e,
456
                    name,
457
                    id
458
                );
459
            });
460
    }
461

462
    /**
463
     * Checks if paste is allowed into current plugin/placeholder based
464
     * on restrictions we have. Also determines which tooltip to show.
465
     *
466
     * WARNING: this relies on clipboard plugins always being instantiated
467
     * first, so they have data('cms') by the time this method is called.
468
     *
469
     * @method _checkIfPasteAllowed
470
     * @private
471
     * @returns {Boolean}
472
     */
473
    _checkIfPasteAllowed() {
474
        var pasteButton = this.ui.dropdown.find('[data-rel=paste]');
151✔
475
        var pasteItem = pasteButton.parent();
151✔
476

477
        if (!clipboardDraggable.length) {
151✔
478
            pasteItem.addClass('cms-submenu-item-disabled');
86✔
479
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
86✔
480
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
86✔
481
            return false;
86✔
482
        }
483

484
        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
65✔
485
            pasteItem.addClass('cms-submenu-item-disabled');
45✔
486
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
45✔
487
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
45✔
488
            return false;
45✔
489
        }
490

491
        var bounds = this.options.plugin_restriction;
20✔
492

493
        if (clipboardDraggable.data('cms')) {
20!
494
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
495
            var type = clipboardPluginData.plugin_type;
20✔
496

497
            // The target's child list (bounds) already encodes which plugins it accepts -- including
498
            // the placeholder's root list, which excludes plugins that require a parent. So a single
499
            // membership check is enough.
500
            if (bounds.length && $.inArray(type, bounds) === -1) {
20!
501
                pasteItem.addClass('cms-submenu-item-disabled');
×
502
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
×
503
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
×
504
                return false;
×
505
            }
506
        } else {
507
            return false;
×
508
        }
509

510
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
20✔
511
        pasteItem.removeClass('cms-submenu-item-disabled');
20✔
512

513
        return true;
20✔
514
    }
515

516
    /**
517
     * Calls api to create a plugin and then proceeds to edit it.
518
     *
519
     * @method addPlugin
520
     * @param {String} type type of the plugin, e.g "Bootstrap3ColumnCMSPlugin"
521
     * @param {String} name name of the plugin, e.g. "Column"
522
     * @param {String} parent id of a parent plugin
523
     * @param {Boolean} showAddForm if false, will NOT show the add form
524
     * @param {Number} position (optional) position of the plugin
525
     */
526
    // eslint-disable-next-line max-params
527
    addPlugin(type, name, parent, showAddForm = true, position) {
2✔
528
        var params = {
4✔
529
            placeholder_id: this.options.placeholder_id,
530
            plugin_type: type,
531
            cms_path: path,
532
            plugin_language: CMS.config.request.language,
533
            plugin_position: position || this._getPluginAddPosition()
8✔
534
        };
535

536
        if (parent) {
4✔
537
            params.plugin_parent = parent;
2✔
538
        }
539
        var url = this.options.urls.add_plugin + '?' + $.param(params);
4✔
540

541
        const modal = new Modal({
4✔
542
            onClose: this.options.onClose || false,
7✔
543
            redirectOnClose: this.options.redirectOnClose || false
7✔
544
        });
545

546
        if (showAddForm) {
4✔
547
            modal.open({
3✔
548
                url: url,
549
                title: name
550
            });
551
        } else {
552
            // Also open the modal but without the content. Instead create a form and immediately submit it.
553
            modal.open({
1✔
554
                url: '#',
555
                title: name
556
            });
557
            if (modal.ui) {
1!
558
                // Hide the plugin type selector modal if it's open
559
                modal.ui.modal.hide();
1✔
560
            }
561
            const contents = modal.ui.frame.find('iframe').contents();
1✔
562
            const body = contents.find('body');
1✔
563

564
            body.append(`<form method="post" action="${url}" style="display: none;">
1✔
565
                <input type="hidden" name="csrfmiddlewaretoken" value="${CMS.config.csrf}"></form>`);
566
            body.find('form').submit();
1✔
567
        }
568
        this.modal = modal;
4✔
569

570
        Helpers.removeEventListener('modal-closed.add-plugin');
4✔
571
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
4✔
572
            if (instance !== modal) {
1!
573
                return;
×
574
            }
575
            Plugin._removeAddPluginPlaceholder();
1✔
576
        });
577
    }
578

579
    _getPluginAddPosition() {
580
        if (this.options.type === 'placeholder') {
×
581
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
582
        }
583

584
        // assume plugin now
585
        // would prefer to get the information from the tree, but the problem is that the flat data
586
        // isn't sorted by position
587
        const maybeChildren = this.ui.draggable.find('.cms-draggable');
×
588

589
        if (maybeChildren.length) {
×
590
            const lastChild = maybeChildren.last();
×
591

592
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
593

594
            return lastChildInstance.options.position + 1;
×
595
        }
596

597
        return this.options.position + 1;
×
598
    }
599

600
    /**
601
     * Opens the modal for editing a plugin.
602
     *
603
     * @method editPlugin
604
     * @param {String} url editing url
605
     * @param {String} name Name of the plugin, e.g. "Column"
606
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
607
     *     each item is `{ title: 'string': url: 'string' }`
608
     */
609
    editPlugin(url, name, breadcrumb) {
610
        // trigger modal window
611
        var modal = new Modal({
3✔
612
            onClose: this.options.onClose || false,
6✔
613
            redirectOnClose: this.options.redirectOnClose || false
6✔
614
        });
615

616
        this.modal = modal;
3✔
617

618
        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
3✔
619
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
3✔
620
            if (instance === modal) {
1!
621
                // cannot be cached
622
                Plugin._removeAddPluginPlaceholder();
1✔
623
            }
624
        });
625
        modal.open({
3✔
626
            url: url,
627
            title: name,
628
            breadcrumbs: breadcrumb,
629
            width: 850
630
        });
631
    }
632

633
    /**
634
     * Used for copying _and_ pasting a plugin. If either of params
635
     * is present method assumes that it's "paste" and will make a call
636
     * to api to insert current plugin to specified `options.target_plugin_id`
637
     * or `options.target_placeholder_id`. Copying a plugin also first
638
     * clears the clipboard.
639
     *
640
     * @method copyPlugin
641
     * @param {Object} [opts=this.options]
642
     * @param {String} source_language
643
     * @returns {Boolean|void}
644
     */
645

646
    copyPlugin(opts, source_language) {
647
        // cancel request if already in progress
648
        if (CMS.API.locked) {
9✔
649
            return false;
1✔
650
        }
651
        CMS.API.locked = true;
8✔
652

653
        // set correct options (don't mutate them)
654
        var options = $.extend({}, opts || this.options);
8✔
655
        var sourceLanguage = source_language;
8✔
656
        let copyingFromLanguage = false;
8✔
657

658
        if (sourceLanguage) {
8✔
659
            copyingFromLanguage = true;
1✔
660
            options.target = options.placeholder_id;
1✔
661
            options.plugin_id = '';
1✔
662
            options.parent = '';
1✔
663
        } else {
664
            sourceLanguage = CMS.config.request.language;
7✔
665
        }
666

667
        var data = {
8✔
668
            source_placeholder_id: options.placeholder_id,
669
            source_plugin_id: options.plugin_id || '',
9✔
670
            source_language: sourceLanguage,
671
            target_plugin_id: options.parent || '',
16✔
672
            target_placeholder_id: options.target || CMS.config.clipboard.id,
15✔
673
            csrfmiddlewaretoken: CMS.config.csrf,
674
            target_language: CMS.config.request.language
675
        };
676
        var request = {
8✔
677
            type: 'POST',
678
            url: Helpers.updateUrlWithPath(options.urls.copy_plugin),
679
            data: data,
680
            success(response) {
681
                CMS.API.Messages.open({
2✔
682
                    message: CMS.config.lang.success
683
                });
684
                if (copyingFromLanguage) {
2!
685
                    CMS.API.StructureBoard.invalidateState('PASTE', $.extend({}, data, response));
×
686
                } else {
687
                    CMS.API.StructureBoard.invalidateState('COPY', response);
2✔
688
                }
689
                CMS.API.locked = false;
2✔
690
                hideLoader();
2✔
691
            },
692
            error(jqXHR) {
693
                CMS.API.locked = false;
3✔
694
                var msg = CMS.config.lang.error;
3✔
695

696
                // trigger error
697
                CMS.API.Messages.open({
3✔
698
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
699
                    error: true
700
                });
701
            }
702
        };
703

704
        $.ajax(request);
8✔
705
    }
706

707
    /**
708
     * Essentially clears clipboard and moves plugin to a clipboard
709
     * placholder through `movePlugin`.
710
     *
711
     * @method cutPlugin
712
     * @returns {Boolean|void}
713
     */
714
    cutPlugin() {
715
        // if cut is once triggered, prevent additional actions
716
        if (CMS.API.locked) {
9✔
717
            return false;
1✔
718
        }
719
        CMS.API.locked = true;
8✔
720

721
        var that = this;
8✔
722
        var data = {
8✔
723
            placeholder_id: CMS.config.clipboard.id,
724
            plugin_id: this.options.plugin_id,
725
            plugin_parent: '',
726
            target_language: CMS.config.request.language,
727
            csrfmiddlewaretoken: CMS.config.csrf
728
        };
729

730
        // move plugin
731
        $.ajax({
8✔
732
            type: 'POST',
733
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
734
            data: data,
735
            success(response) {
736
                CMS.API.locked = false;
4✔
737
                CMS.API.Messages.open({
4✔
738
                    message: CMS.config.lang.success
739
                });
740
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
4✔
741
                hideLoader();
4✔
742
            },
743
            error(jqXHR) {
744
                CMS.API.locked = false;
3✔
745
                var msg = CMS.config.lang.error;
3✔
746

747
                // trigger error
748
                CMS.API.Messages.open({
3✔
749
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
750
                    error: true
751
                });
752
                hideLoader();
3✔
753
            }
754
        });
755
    }
756

757
    /**
758
     * Method is called when you click on the paste button on the plugin.
759
     * Uses existing solution of `copyPlugin(options)`
760
     *
761
     * @method pastePlugin
762
     */
763
    pastePlugin() {
764
        var id = this._getId(clipboardDraggable);
5✔
765
        var eventData = {
5✔
766
            id: id
767
        };
768

769
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
770

771
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
772
        if (this.options.plugin_id) {
5✔
773
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
774
        }
775
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
776
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
777
    }
778

779
    /**
780
     * Moves plugin by querying the API and then updates some UI parts
781
     * to reflect that the page has changed.
782
     *
783
     * @method movePlugin
784
     * @param {Object} [opts=this.options]
785
     * @param {String} [opts.placeholder_id]
786
     * @param {String} [opts.plugin_id]
787
     * @param {String} [opts.plugin_parent]
788
     * @param {Boolean} [opts.move_a_copy]
789
     * @returns {Boolean|void}
790
     */
791
    movePlugin(opts) {
792
        // cancel request if already in progress
793
        if (CMS.API.locked) {
12✔
794
            return false;
1✔
795
        }
796
        CMS.API.locked = true;
11✔
797

798
        // set correct options
799
        const options = opts || this.options;
11✔
800

801
        const dragitem = $(`.cms-draggable-${options.plugin_id}:last`);
11✔
802

803
        // SAVING POSITION
804
        const placeholder_id = this._getId(dragitem.parents('.cms-draggables').last().prevAll('.cms-dragbar').first());
11✔
805

806
        // cancel here if we have no placeholder id
807
        if (placeholder_id === false) {
11✔
808
            return false;
1✔
809
        }
810
        const pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
811
        const plugin_parent = this._getId(pluginParentElement);
10✔
812

813
        // gather the data for ajax request
814
        const data = {
10✔
815
            plugin_id: options.plugin_id,
816
            plugin_parent: plugin_parent || '',
20✔
817
            target_language: CMS.config.request.language,
818
            csrfmiddlewaretoken: CMS.config.csrf,
819
            move_a_copy: options.move_a_copy
820
        };
821

822
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
823
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
824
        } else {
825
            data.placeholder_id = placeholder_id;
×
826

827
            Plugin._updatePluginPositions(placeholder_id);
×
828
            Plugin._updatePluginPositions(options.placeholder_id);
×
829
        }
830

831
        const position = this.options.position;
10✔
832

833
        data.target_position = position;
10✔
834

835
        showLoader();
10✔
836

837
        $.ajax({
10✔
838
            type: 'POST',
839
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
840
            data: data,
841
            success: response => {
842
                CMS.API.StructureBoard.invalidateState(
4✔
843
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
844
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
845
                );
846

847
                // enable actions again
848
                CMS.API.locked = false;
4✔
849
                hideLoader();
4✔
850
            },
851
            error: jqXHR => {
852
                CMS.API.locked = false;
4✔
853
                const msg = CMS.config.lang.error;
4✔
854

855
                // trigger error
856
                CMS.API.Messages.open({
4✔
857
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
858
                    error: true
859
                });
860
                hideLoader();
4✔
861
            }
862
        });
863
    }
864

865
    /**
866
     * Changes the settings attributes on an initialised plugin.
867
     *
868
     * @method _setSettings
869
     * @param {Object} oldSettings current settings
870
     * @param {Object} newSettings new settings to be applied
871
     * @private
872
     */
873
    _setSettings(oldSettings, newSettings) {
874
        var settings = $.extend(true, {}, oldSettings, newSettings);
×
875
        var plugin = $('.cms-plugin-' + settings.plugin_id);
×
876
        var draggable = $('.cms-draggable-' + settings.plugin_id);
×
877

878
        // set new setting on instance and plugin data
879
        this.options = settings;
×
880
        if (plugin.length) {
×
881
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
882
                return pluginData.plugin_id === settings.plugin_id;
×
883
            });
884

885
            plugin.each(function() {
×
886
                $(this).data('cms')[index] = settings;
×
887
            });
888
        }
889
        if (draggable.length) {
×
890
            draggable.data('cms', settings);
×
891
        }
892
    }
893

894
    /**
895
     * Opens a modal to delete a plugin.
896
     *
897
     * @method deletePlugin
898
     * @param {String} url admin url for deleting a page
899
     * @param {String} name plugin name, e.g. "Column"
900
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
901
     *     each item is `{ title: 'string': url: 'string' }`
902
     */
903
    deletePlugin(url, name, breadcrumb) {
904
        // trigger modal window
905
        var modal = new Modal({
2✔
906
            onClose: this.options.onClose || false,
4✔
907
            redirectOnClose: this.options.redirectOnClose || false
4✔
908
        });
909

910
        this.modal = modal;
2✔
911

912
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
913
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
914
            if (instance === modal) {
5✔
915
                Plugin._removeAddPluginPlaceholder();
1✔
916
            }
917
        });
918
        modal.open({
2✔
919
            url: url,
920
            title: name,
921
            breadcrumbs: breadcrumb
922
        });
923
    }
924

925
    /**
926
     * Destroys the current plugin instance removing only the DOM listeners
927
     *
928
     * @method destroy
929
     * @param {Object}  options - destroy config options
930
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
931
     * @returns {void}
932
     */
933
    destroy(options = {}) {
1✔
934
        const mustCleanup = options.mustCleanup || false;
2✔
935

936
        // close the plugin modal if it was open
937
        if (this.modal) {
2!
938
            this.modal.close();
×
939
            // unsubscribe to all the modal events
940
            this.modal.off();
×
941
        }
942

943
        if (mustCleanup) {
2✔
944
            this.cleanup();
1✔
945
        }
946

947
        // remove event bound to global elements like document or window
948
        $document.off(`.${this.uid}`);
2✔
949
        $window.off(`.${this.uid}`);
2✔
950
    }
951

952
    /**
953
     * Remove the plugin specific ui elements from the DOM
954
     *
955
     * @method cleanup
956
     * @returns {void}
957
     */
958
    cleanup() {
959
        // remove all the plugin UI DOM elements
960
        // notice that $.remove will remove also all the ui specific events
961
        // previously attached to them
962
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
12✔
963
    }
964

965
    /**
966
     * Called after plugin is added through ajax.
967
     *
968
     * @method editPluginPostAjax
969
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
970
     * @param {Object} response response from server
971
     */
972
    editPluginPostAjax(toolbar, response) {
973
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
974
    }
975

976
    /**
977
     * _setSettingsMenu sets up event handlers for settings menu.
978
     *
979
     * @method _setSettingsMenu
980
     * @private
981
     * @param {jQuery} nav
982
     */
983
    _setSettingsMenu(nav) {
984
        var that = this;
153✔
985

986
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
987
        var dropdown = this.ui.dropdown;
153✔
988

989
        nav
153✔
990
            .off(Plugin.pointerUp)
991
            .on(Plugin.pointerUp, function(e) {
992
                e.preventDefault();
×
993
                e.stopPropagation();
×
994
                var trigger = $(this);
×
995

996
                if (trigger.hasClass('cms-btn-active')) {
×
997
                    Plugin._hideSettingsMenu(trigger);
×
998
                } else {
999
                    Plugin._hideSettingsMenu();
×
1000
                    that._showSettingsMenu(trigger);
×
1001
                }
1002
            })
1003
            .off(Plugin.touchStart)
1004
            .on(Plugin.touchStart, function(e) {
1005
                // required on some touch devices so
1006
                // ui touch punch is not triggering mousemove
1007
                // which in turn results in pep triggering pointercancel
1008
                e.stopPropagation();
×
1009
            });
1010

1011
        dropdown
153✔
1012
            .off(Plugin.mouseEvents)
1013
            .on(Plugin.mouseEvents, function(e) {
1014
                e.stopPropagation();
×
1015
            })
1016
            .off(Plugin.touchStart)
1017
            .on(Plugin.touchStart, function(e) {
1018
                // required for scrolling on mobile
1019
                e.stopPropagation();
×
1020
            });
1021

1022
        that._setupActions(nav);
153✔
1023
        // prevent propagation
1024
        nav
153✔
1025
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
1026
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1027
                e.stopPropagation();
×
1028
            });
1029

1030
        nav
153✔
1031
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
1032
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
1033
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1034
                e.stopPropagation();
×
1035
            });
1036
    }
1037

1038
    /**
1039
     * Simplistic implementation, only scrolls down, only works in structuremode
1040
     * and highly depends on the styles of the structureboard to work correctly
1041
     *
1042
     * @method _scrollToElement
1043
     * @private
1044
     * @param {jQuery} el element to scroll to
1045
     * @param {Object} [opts]
1046
     * @param {Number} [opts.duration=200] time to scroll
1047
     * @param {Number} [opts.offset=50] distance in px to the bottom of the screen
1048
     */
1049
    _scrollToElement(el, opts) {
1050
        var DEFAULT_DURATION = 200;
3✔
1051
        var DEFAULT_OFFSET = 50;
3✔
1052
        var duration = opts && opts.duration !== undefined ? opts.duration : DEFAULT_DURATION;
3✔
1053
        var offset = opts && opts.offset !== undefined ? opts.offset : DEFAULT_OFFSET;
3✔
1054
        var scrollable = el.offsetParent();
3✔
1055
        var scrollHeight = $window.height();
3✔
1056
        var scrollTop = scrollable.scrollTop();
3✔
1057
        var elPosition = el.position().top;
3✔
1058
        var elHeight = el.height();
3✔
1059
        var isInViewport = elPosition + elHeight + offset <= scrollHeight;
3✔
1060

1061
        if (!isInViewport) {
3✔
1062
            scrollable.animate(
2✔
1063
                {
1064
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1065
                },
1066
                duration
1067
            );
1068
        }
1069
    }
1070

1071
    /**
1072
     * Opens a modal with traversable plugins list, adds a placeholder to where
1073
     * the plugin will be added.
1074
     *
1075
     * @method _setAddPluginModal
1076
     * @private
1077
     * @param {jQuery} nav modal trigger element
1078
     * @returns {Boolean|void}
1079
     */
1080
    _setAddPluginModal(nav) {
1081
        if (nav.hasClass('cms-btn-disabled')) {
153✔
1082
            return false;
88✔
1083
        }
1084
        var that = this;
65✔
1085
        var modal;
1086
        var possibleChildClasses;
1087
        var isTouching;
1088
        var plugins;
1089

1090
        var initModal = once(function initModal() {
65✔
1091
            var placeholder = $(
×
1092
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1093
            );
1094
            var dragItem = nav.closest('.cms-dragitem');
×
1095
            var isPlaceholder = !dragItem.length;
×
1096
            var childrenList;
1097

1098
            modal = new Modal({
×
1099
                minWidth: 400,
1100
                minHeight: 400
1101
            });
1102

1103
            if (isPlaceholder) {
×
1104
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1105
            } else {
1106
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1107
            }
1108

1109
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
1110
                if (instance !== modal) {
×
1111
                    return;
×
1112
                }
1113

1114
                that._setupKeyboardTraversing();
×
1115
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
1116
                    that._toggleCollapsable(dragItem);
×
1117
                }
1118
                Plugin._removeAddPluginPlaceholder();
×
1119
                placeholder.appendTo(childrenList);
×
1120
                that._scrollToElement(placeholder);
×
1121
            });
1122

1123
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
1124
                if (instance !== modal) {
×
1125
                    return;
×
1126
                }
1127
                Plugin._removeAddPluginPlaceholder();
×
1128
            });
1129

1130
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
1131
                if (modal !== instance) {
×
1132
                    return;
×
1133
                }
1134
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1135

1136
                if (!isTouching) {
×
1137
                    // only focus the field if using mouse
1138
                    // otherwise keyboard pops up
1139
                    dropdown.find('input').trigger('focus');
×
1140
                }
1141
                isTouching = false;
×
1142
            });
1143

1144
            plugins = nav.siblings('.cms-plugin-picker');
×
1145

1146
            that._setupQuickSearch(plugins);
×
1147
        });
1148

1149
        nav
65✔
1150
            .on(Plugin.touchStart, function(e) {
1151
                isTouching = true;
×
1152
                // required on some touch devices so
1153
                // ui touch punch is not triggering mousemove
1154
                // which in turn results in pep triggering pointercancel
1155
                e.stopPropagation();
×
1156
            })
1157
            .on(Plugin.pointerUp, function(e) {
1158
                e.preventDefault();
×
1159
                e.stopPropagation();
×
1160

1161
                Plugin._hideSettingsMenu();
×
1162

1163
                possibleChildClasses = that._getPossibleChildClasses();
×
1164
                var selectionNeeded = possibleChildClasses.filter(':not(.cms-submenu-item-title)').length !== 1;
×
1165

1166
                if (selectionNeeded) {
×
1167
                    initModal();
×
1168

1169
                    // since we don't know exact plugin parent (because dragndrop)
1170
                    // we need to know the parent id by the time we open "add plugin" dialog
1171
                    var pluginsCopy = that._updateWithMostUsedPlugins(
×
1172
                        plugins
1173
                            .clone(true, true)
1174
                            .data('parentId', that._getId(nav.closest('.cms-draggable')))
1175
                            .append(possibleChildClasses)
1176
                    );
1177

1178
                    modal.open({
×
1179
                        title: that.options.addPluginHelpTitle,
1180
                        html: pluginsCopy,
1181
                        width: 530,
1182
                        height: 400
1183
                    });
1184
                } else {
1185
                    // only one plugin available, no need to show the modal
1186
                    // instead directly add the single plugin
1187
                    const el = possibleChildClasses.find('a'); // only one result
×
1188
                    const pluginType = el.attr('href').replace('#', '');
×
1189
                    const showAddForm = el.data('addForm');
×
1190
                    const parentId = that._getId(nav.closest('.cms-draggable'));
×
1191

1192
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1193
                }
1194
            });
1195

1196
        // prevent propagation
1197
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
1198
            e.stopPropagation();
×
1199
        });
1200

1201
        nav
65✔
1202
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1203
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1204
                e.stopPropagation();
×
1205
            });
1206
    }
1207

1208
    _updateWithMostUsedPlugins(plugins) {
1209
        const items = plugins.find('.cms-submenu-item');
×
1210
        // eslint-disable-next-line no-unused-vars
1211
        const mostUsedPlugins = Object.entries(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
1212
        const MAX_MOST_USED_PLUGINS = 5;
×
1213
        let count = 0;
×
1214

1215
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
1216
            return plugins;
×
1217
        }
1218

1219
        let ref = plugins.find('.cms-quicksearch');
×
1220

1221
        mostUsedPlugins.forEach(([name]) => {
×
1222
            if (count === MAX_MOST_USED_PLUGINS) {
×
1223
                return;
×
1224
            }
1225
            const item = items.find(`[href=${name}]`);
×
1226

1227
            if (item.length) {
×
1228
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1229

1230
                ref.after(clone);
×
1231
                ref = clone;
×
1232
                count += 1;
×
1233
            }
1234
        });
1235

1236
        if (count) {
×
1237
            plugins.find('.cms-quicksearch').after(
×
1238
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1239
                    <span>${CMS.config.lang.mostUsed}</span>
1240
                </div>`)
1241
            );
1242
        }
1243

1244
        return plugins;
×
1245
    }
1246

1247
    /**
1248
     * Returns a specific plugin namespaced event postfixing the plugin uid to it
1249
     * in order to properly manage it via jQuery $.on and $.off
1250
     *
1251
     * @method _getNamepacedEvent
1252
     * @private
1253
     * @param {String} base - plugin event type
1254
     * @param {String} additionalNS - additional namespace (like '.traverse' for example)
1255
     * @returns {String} a specific plugin event
1256
     *
1257
     * @example
1258
     *
1259
     * plugin._getNamepacedEvent(Plugin.click); // 'click.cms.plugin.42'
1260
     * plugin._getNamepacedEvent(Plugin.keyDown, '.traverse'); // 'keydown.cms.plugin.traverse.42'
1261
     */
1262
    _getNamepacedEvent(base, additionalNS = '') {
133✔
1263
        return `${base}${additionalNS ? '.'.concat(additionalNS) : ''}.${this.uid}`;
144✔
1264
    }
1265

1266
    /**
1267
     * Returns available plugin/placeholder child classes markup
1268
     * for "Add plugin" modal
1269
     *
1270
     * @method _getPossibleChildClasses
1271
     * @private
1272
     * @returns {jQuery} "add plugin" menu
1273
     */
1274
    _getPossibleChildClasses() {
1275
        var that = this;
33✔
1276
        var childRestrictions = this.options.plugin_restriction;
33✔
1277
        // have to check the placeholder every time, since plugin could've been
1278
        // moved as part of another plugin
1279
        var placeholderId = that._getId(that.ui.submenu.closest('.cms-dragarea'));
33✔
1280
        var resultElements = $($('#cms-plugin-child-classes-' + placeholderId).html());
33✔
1281

1282
        if (childRestrictions && childRestrictions.length) {
33✔
1283
            resultElements = resultElements.filter(function() {
29✔
1284
                var item = $(this);
4,727✔
1285

1286
                return (
4,727✔
1287
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1288
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1289
                );
1290
            });
1291

1292
            resultElements = resultElements.filter(function(index) {
29✔
1293
                var item = $(this);
411✔
1294

1295
                return (
411✔
1296
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1297
                    (item.hasClass('cms-submenu-item-title') &&
1298
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1299
                            resultElements.eq(index + 1).length))
1300
                );
1301
            });
1302
        }
1303

1304
        resultElements.find('a').on(Plugin.click, e => this._delegate(e));
33✔
1305

1306
        return resultElements;
33✔
1307
    }
1308

1309
    /**
1310
     * Sets up event handlers for quicksearching in the plugin picker.
1311
     *
1312
     * @method _setupQuickSearch
1313
     * @private
1314
     * @param {jQuery} plugins plugins picker element
1315
     */
1316
    _setupQuickSearch(plugins) {
1317
        var that = this;
×
1318
        var FILTER_DEBOUNCE_TIMER = 100;
×
1319
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1320

1321
        var handler = debounce(function() {
×
1322
            var input = $(this);
×
1323
            // have to always find the pluginsPicker in the handler
1324
            // because of how we move things into/out of the modal
1325
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1326

1327
            that._filterPluginsList(pluginsPicker, input);
×
1328
        }, FILTER_DEBOUNCE_TIMER);
1329

1330
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1331
            Plugin.keyUp,
1332
            debounce(function(e) {
1333
                var input;
1334
                var pluginsPicker;
1335

1336
                if (e.keyCode === KEYS.ENTER) {
×
1337
                    input = $(this);
×
1338
                    pluginsPicker = input.closest('.cms-plugin-picker');
×
1339
                    pluginsPicker
×
1340
                        .find('.cms-submenu-item')
1341
                        .not('.cms-submenu-item-title')
1342
                        .filter(':visible')
1343
                        .first()
1344
                        .find('> a')
1345
                        .focus()
1346
                        .trigger('click');
1347
                }
1348
            }, FILTER_PICK_DEBOUNCE_TIMER)
1349
        );
1350
    }
1351

1352
    /**
1353
     * Sets up click handlers for various plugin/placeholder items.
1354
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
1355
     *
1356
     * @method _setupActions
1357
     * @private
1358
     * @param {jQuery} nav dropdown trigger with the items
1359
     */
1360
    _setupActions(nav) {
1361
        var items = '.cms-submenu-edit, .cms-submenu-item a';
163✔
1362
        var parent = nav.parent();
163✔
1363

1364
        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
163✔
1365
            // required on some touch devices so
1366
            // ui touch punch is not triggering mousemove
1367
            // which in turn results in pep triggering pointercancel
1368
            e.stopPropagation();
1✔
1369
        });
1370
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
163✔
1371
    }
1372

1373
    /**
1374
     * Handler for the "action" items
1375
     *
1376
     * @method _delegate
1377
     * @param {$.Event} e event
1378
     * @private
1379
     */
1380
    // eslint-disable-next-line complexity
1381
    _delegate(e) {
1382
        e.preventDefault();
13✔
1383
        e.stopPropagation();
13✔
1384

1385
        var nav;
1386
        var that = this;
13✔
1387

1388
        if (e.data && e.data.nav) {
13!
1389
            nav = e.data.nav;
×
1390
        }
1391

1392
        // show loader and make sure scroll doesn't jump
1393
        showLoader();
13✔
1394

1395
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1396
        var el = $(e.target).closest(items);
13✔
1397

1398
        Plugin._hideSettingsMenu(nav);
13✔
1399

1400
        // set switch for subnav entries
1401
        switch (el.attr('data-rel')) {
13!
1402

1403
            case 'add': {
1404
                const pluginType = el.attr('href').replace('#', '');
2✔
1405
                const showAddForm = el.data('addForm');
2✔
1406

1407
                Plugin._updateUsageCount(pluginType);
2✔
1408
                that.addPlugin(pluginType, el.text(), el.closest('.cms-plugin-picker').data('parentId'), showAddForm);
2✔
1409
                break;
2✔
1410
            }
1411
            case 'ajax_add':
1412
                CMS.API.Toolbar.openAjax({
1✔
1413
                    url: el.attr('href'),
1414
                    post: JSON.stringify(el.data('post')),
1415
                    text: el.data('text'),
1416
                    callback: $.proxy(that.editPluginPostAjax, that),
1417
                    onSuccess: el.data('on-success')
1418
                });
1419
                break;
1✔
1420
            case 'edit':
1421
                that.editPlugin(
1✔
1422
                    Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
1423
                    that.options.plugin_name,
1424
                    that._getPluginBreadcrumbs()
1425
                );
1426
                break;
1✔
1427
            case 'copy-lang':
1428
                that.copyPlugin(that.options, el.attr('data-language'));
1✔
1429
                break;
1✔
1430
            case 'copy':
1431
                if (el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1432
                    hideLoader();
1✔
1433
                } else {
1434
                    that.copyPlugin();
1✔
1435
                }
1436
                break;
2✔
1437
            case 'cut':
1438
                that.cutPlugin();
1✔
1439
                break;
1✔
1440
            case 'paste':
1441
                hideLoader();
2✔
1442
                if (!el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1443
                    that.pastePlugin();
1✔
1444
                }
1445
                break;
2✔
1446
            case 'delete':
1447
                that.deletePlugin(
1✔
1448
                    Helpers.updateUrlWithPath(that.options.urls.delete_plugin),
1449
                    that.options.plugin_name,
1450
                    that._getPluginBreadcrumbs()
1451
                );
1452
                break;
1✔
1453
            case 'highlight':
1454
                hideLoader();
×
1455

1456
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
×
1457
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
×
1458
                e.stopImmediatePropagation();
×
1459
                break;
×
1460
            default:
1461
                hideLoader();
2✔
1462
                CMS.API.Toolbar._delegate(el);
2✔
1463
        }
1464
    }
1465

1466
    /**
1467
     * Sets up keyboard traversing of plugin picker.
1468
     *
1469
     * @method _setupKeyboardTraversing
1470
     * @private
1471
     */
1472
    _setupKeyboardTraversing() {
1473
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1474
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1475

1476
        if (!dropdown.length) {
3✔
1477
            return;
1✔
1478
        }
1479
        // add key events
1480
        $document.off(keyDownTraverseEvent);
2✔
1481
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
1482
        $document.on(keyDownTraverseEvent, function(e) {
1483
            var anchors = dropdown.find('.cms-submenu-item:visible a');
1484
            var index = anchors.index(anchors.filter(':focus'));
1485

1486
            // bind arrow down and tab keys
1487
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1488
                e.preventDefault();
1489
                if (index >= 0 && index < anchors.length - 1) {
1490
                    anchors.eq(index + 1).focus();
1491
                } else {
1492
                    anchors.eq(0).focus();
1493
                }
1494
            }
1495

1496
            // bind arrow up and shift+tab keys
1497
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1498
                e.preventDefault();
1499
                if (anchors.is(':focus')) {
1500
                    anchors.eq(index - 1).focus();
1501
                } else {
1502
                    anchors.eq(anchors.length).focus();
1503
                }
1504
            }
1505
        });
1506
    }
1507

1508
    /**
1509
     * Opens the settings menu for a plugin.
1510
     *
1511
     * @method _showSettingsMenu
1512
     * @private
1513
     * @param {jQuery} nav trigger element
1514
     */
1515
    _showSettingsMenu(nav) {
1516
        this._checkIfPasteAllowed();
×
1517

1518
        var dropdown = this.ui.dropdown;
×
1519
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
1520
        var MIN_SCREEN_MARGIN = 10;
×
1521

1522
        nav.addClass('cms-btn-active');
×
1523
        parents.addClass('cms-z-index-9999');
×
1524

1525
        // set visible states
1526
        dropdown.show();
×
1527

1528
        // calculate dropdown positioning
1529
        if (
×
1530
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1531
            nav.offset().top - dropdown.height() >= 0
1532
        ) {
1533
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1534
        } else {
1535
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1536
        }
1537
    }
1538

1539
    /**
1540
     * Filters given plugins list by a query.
1541
     *
1542
     * @method _filterPluginsList
1543
     * @private
1544
     * @param {jQuery} list plugins picker element
1545
     * @param {jQuery} input input, which value to filter plugins with
1546
     * @returns {Boolean|void}
1547
     */
1548
    _filterPluginsList(list, input) {
1549
        var items = list.find('.cms-submenu-item');
5✔
1550
        var titles = list.find('.cms-submenu-item-title');
5✔
1551
        var query = input.val();
5✔
1552

1553
        // cancel if query is zero
1554
        if (query === '') {
5✔
1555
            items.add(titles).show();
1✔
1556
            return false;
1✔
1557
        }
1558

1559
        var mostRecentItems = list.find('.cms-submenu-item[data-cms-most-used]');
4✔
1560

1561
        mostRecentItems = mostRecentItems.add(mostRecentItems.nextUntil('.cms-submenu-item-title'));
4✔
1562

1563
        // Simple case-insensitive substring matching (replaces fuzzyFilter)
1564
        var queryLower = query.toLowerCase();
4✔
1565

1566
        items.hide();
4✔
1567
        items.each(function() {
4✔
1568
            var item = $(this);
72✔
1569
            var text = item.text().toLowerCase();
72✔
1570

1571
            if (text.indexOf(queryLower) !== -1) {
72✔
1572
                item.show();
3✔
1573
            }
1574
        });
1575

1576
        // check if a title is matching
1577
        titles.filter(':visible').each(function(index, item) {
4✔
1578
            titles.hide();
1✔
1579
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1580
        });
1581

1582
        // always display title of a category
1583
        items.filter(':visible').each(function(index, titleItem) {
4✔
1584
            var item = $(titleItem);
16✔
1585

1586
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1587
                item.prev().show();
2✔
1588
            } else {
1589
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1590
            }
1591
        });
1592

1593
        mostRecentItems.hide();
4✔
1594
    }
1595

1596
    /**
1597
     * Toggles collapsable item.
1598
     *
1599
     * @method _toggleCollapsable
1600
     * @private
1601
     * @param {jQuery} el element to toggle
1602
     * @returns {Boolean|void}
1603
     */
1604
    _toggleCollapsable(el) {
1605
        var that = this;
×
1606
        var id = that._getId(el.parent());
×
1607
        var draggable = el.closest('.cms-draggable');
×
1608
        var items;
1609

1610
        var settings = CMS.settings;
×
1611

1612
        settings.states = settings.states || [];
×
1613

1614
        if (!draggable || !draggable.length) {
×
1615
            return;
×
1616
        }
1617

1618
        // collapsable function and save states
1619
        if (el.hasClass('cms-dragitem-expanded')) {
×
1620
            settings.states.splice($.inArray(id, settings.states), 1);
×
1621
            el
×
1622
                .removeClass('cms-dragitem-expanded')
1623
                .parent()
1624
                .find('> .cms-collapsable-container')
1625
                .addClass('cms-hidden');
1626

1627
            if ($document.data('expandmode')) {
×
1628
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1629
                if (!items.length) {
×
1630
                    return false;
×
1631
                }
1632
                items.each(function() {
×
1633
                    var item = $(this);
×
1634

1635
                    if (item.hasClass('cms-dragitem-expanded')) {
×
1636
                        that._toggleCollapsable(item);
×
1637
                    }
1638
                });
1639
            }
1640
        } else {
1641
            settings.states.push(id);
×
1642
            el
×
1643
                .addClass('cms-dragitem-expanded')
1644
                .parent()
1645
                .find('> .cms-collapsable-container')
1646
                .removeClass('cms-hidden');
1647

1648
            if ($document.data('expandmode')) {
×
1649
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1650
                if (!items.length) {
×
1651
                    return false;
×
1652
                }
1653
                items.each(function() {
×
1654
                    var item = $(this);
×
1655

1656
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1657
                        that._toggleCollapsable(item);
×
1658
                    }
1659
                });
1660
            }
1661
        }
1662

1663
        this._updatePlaceholderCollapseState();
×
1664

1665
        // make sure structurboard gets updated after expanding
1666
        $document.trigger('resize.sideframe');
×
1667

1668
        // save settings
1669
        Helpers.setSettings(settings);
×
1670
    }
1671

1672
    _updatePlaceholderCollapseState() {
1673
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1674
            return;
×
1675
        }
1676

1677
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1678
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1679
            .map(([, o]) => o.plugin_id);
×
1680

1681
        const openedPlugins = CMS.settings.states;
×
1682
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
1683
        const areAllRemainingPluginsLeafs = closedPlugins.every(id => {
×
1684
            return !CMS._plugins.find(
×
1685
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1686
            );
1687
        });
1688
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
1689
        var settings = CMS.settings;
×
1690

1691
        if (areAllRemainingPluginsLeafs) {
×
1692
            // meaning that all plugins in current placeholder are expanded
1693
            el.addClass('cms-dragbar-title-expanded');
×
1694

1695
            settings.dragbars = settings.dragbars || [];
×
1696
            settings.dragbars.push(this.options.placeholder_id);
×
1697
        } else {
1698
            el.removeClass('cms-dragbar-title-expanded');
×
1699

1700
            settings.dragbars = settings.dragbars || [];
×
1701
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1702
        }
1703
    }
1704

1705
    /**
1706
     * Sets up collabspable event handlers.
1707
     *
1708
     * @method _collapsables
1709
     * @private
1710
     * @returns {Boolean|void}
1711
     */
1712
    _collapsables() {
1713
        // one time setup
1714
        var that = this;
153✔
1715

1716
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
153✔
1717
        // cancel here if its not a draggable
1718
        if (!this.ui.draggable.length) {
153✔
1719
            return false;
38✔
1720
        }
1721

1722
        var dragitem = this.ui.draggable.find('> .cms-dragitem');
115✔
1723

1724
        // check which button should be shown for collapsemenu
1725
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
115✔
1726
        var open = els.filter('.cms-dragitem-expanded');
115✔
1727

1728
        if (els.length === open.length && els.length + open.length !== 0) {
115!
1729
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1730
        }
1731

1732
        // attach events to draggable
1733
        // debounce here required because on some devices click is not triggered,
1734
        // so we consolidate latest click and touch event to run the collapse only once
1735
        dragitem.find('> .cms-dragitem-text').on(
115✔
1736
            Plugin.touchEnd + ' ' + Plugin.click,
1737
            debounce(function() {
1738
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
×
1739
                    return;
×
1740
                }
1741
                that._toggleCollapsable(dragitem);
×
1742
            }, 0)
1743
        );
1744
    }
1745

1746
    /**
1747
     * Expands all the collapsables in the given placeholder.
1748
     *
1749
     * @method _expandAll
1750
     * @private
1751
     * @param {jQuery} el trigger element that is a child of a placeholder
1752
     * @returns {Boolean|void}
1753
     */
1754
    _expandAll(el) {
1755
        var that = this;
×
1756
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1757

1758
        // cancel if there are no items
1759
        if (!items.length) {
×
1760
            return false;
×
1761
        }
1762
        items.each(function() {
×
1763
            var item = $(this);
×
1764

1765
            if (!item.hasClass('cms-dragitem-expanded')) {
×
1766
                that._toggleCollapsable(item);
×
1767
            }
1768
        });
1769

1770
        el.addClass('cms-dragbar-title-expanded');
×
1771

1772
        var settings = CMS.settings;
×
1773

1774
        settings.dragbars = settings.dragbars || [];
×
1775
        settings.dragbars.push(this.options.placeholder_id);
×
1776
        Helpers.setSettings(settings);
×
1777
    }
1778

1779
    /**
1780
     * Collapses all the collapsables in the given placeholder.
1781
     *
1782
     * @method _collapseAll
1783
     * @private
1784
     * @param {jQuery} el trigger element that is a child of a placeholder
1785
     */
1786
    _collapseAll(el) {
1787
        var that = this;
×
1788
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1789

1790
        items.each(function() {
×
1791
            var item = $(this);
×
1792

1793
            if (item.hasClass('cms-dragitem-expanded')) {
×
1794
                that._toggleCollapsable(item);
×
1795
            }
1796
        });
1797

1798
        el.removeClass('cms-dragbar-title-expanded');
×
1799

1800
        var settings = CMS.settings;
×
1801

1802
        settings.dragbars = settings.dragbars || [];
×
1803
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1804
        Helpers.setSettings(settings);
×
1805
    }
1806

1807
    /**
1808
     * Gets the id of the element, uses CMS.StructureBoard instance.
1809
     *
1810
     * @method _getId
1811
     * @private
1812
     * @param {jQuery} el element to get id from
1813
     * @returns {String}
1814
     */
1815
    _getId(el) {
1816
        return CMS.API.StructureBoard.getId(el);
36✔
1817
    }
1818

1819
    /**
1820
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1821
     *
1822
     * @method _getIds
1823
     * @private
1824
     * @param {jQuery} els elements to get id from
1825
     * @returns {String[]}
1826
     */
1827
    _getIds(els) {
1828
        return CMS.API.StructureBoard.getIds(els);
×
1829
    }
1830

1831
    /**
1832
     * Traverses the registry to find plugin parents
1833
     *
1834
     * @method _getPluginBreadcrumbs
1835
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1836
     * @private
1837
     */
1838
    _getPluginBreadcrumbs() {
1839
        var breadcrumbs = [];
6✔
1840

1841
        breadcrumbs.unshift({
6✔
1842
            title: this.options.plugin_name,
1843
            url: this.options.urls.edit_plugin
1844
        });
1845

1846
        var findParentPlugin = function(id) {
6✔
1847
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1848
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1849
            })[0];
1850
        };
1851

1852
        var id = this.options.plugin_parent;
6✔
1853
        var data;
1854

1855
        while (id && id !== 'None') {
6✔
1856
            data = findParentPlugin(id);
6✔
1857

1858
            if (!data) {
6✔
1859
                break;
1✔
1860
            }
1861

1862
            breadcrumbs.unshift({
5✔
1863
                title: data[1].plugin_name,
1864
                url: data[1].urls.edit_plugin
1865
            });
1866
            id = data[1].plugin_parent;
5✔
1867
        }
1868

1869
        return breadcrumbs;
6✔
1870
    }
1871
}
1872

1873
// Static event names
1874
Plugin.click = 'click.cms.plugin';
1✔
1875
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1876
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1877
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1878
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1879
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1880
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1881
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1882
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1883
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1884

1885
/**
1886
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
1887
 * plugin instances if they didn't exist
1888
 *
1889
 * @method _updateRegistry
1890
 * @private
1891
 * @static
1892
 * @param {Object[]} plugins plugins data
1893
 */
1894
Plugin._updateRegistry = function _updateRegistry(plugins) {
1✔
1895
    plugins.forEach(pluginData => {
×
1896
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
×
1897
        const pluginIndex = CMS._plugins.findIndex(([pluginStr]) => pluginStr === pluginContainer);
×
1898

1899
        if (pluginIndex === -1) {
×
1900
            CMS._plugins.push([pluginContainer, pluginData]);
×
1901
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1902
        } else {
1903
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
1904
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
1905
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1906
        }
1907
    });
1908
};
1909

1910
/**
1911
 * Hides the opened settings menu. By default looks for any open ones.
1912
 *
1913
 * @method _hideSettingsMenu
1914
 * @static
1915
 * @private
1916
 * @param {jQuery} [navEl] element representing the subnav trigger
1917
 */
1918
Plugin._hideSettingsMenu = function(navEl) {
1✔
1919
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');
20✔
1920

1921
    if (!nav.length) {
20!
1922
        return;
20✔
1923
    }
1924
    nav.removeClass('cms-btn-active');
×
1925

1926
    // set correct active state
1927
    nav.closest('.cms-draggable').data('active', false);
×
1928
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1929

1930
    nav.siblings('.cms-submenu-dropdown').hide();
×
1931
    nav.siblings('.cms-quicksearch').hide();
×
1932
    // reset search
1933
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1934

1935
    // reset relativity
1936
    $('.cms-dragbar').css('position', '');
×
1937
};
1938

1939
/**
1940
 * Initialises handlers that affect all plugins and don't make sense
1941
 * in context of each own plugin instance, e.g. listening for a click on a document
1942
 * to hide plugin settings menu should only be applied once, and not every time
1943
 * CMS.Plugin is instantiated.
1944
 *
1945
 * @method _initializeGlobalHandlers
1946
 * @static
1947
 * @private
1948
 */
1949
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
1✔
1950
    var timer;
1951
    var clickCounter = 0;
6✔
1952

1953
    Plugin._updateClipboard();
6✔
1954

1955
    // Structureboard initialized too late
1956
    setTimeout(function() {
6✔
1957
        var pluginData = {};
6✔
1958
        var html = '';
6✔
1959

1960
        if (clipboardDraggable.length) {
6✔
1961
            pluginData = CMS._plugins.find(
5✔
1962
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1963
            )[1];
1964
            html = clipboardDraggable.parent().html();
5✔
1965
        }
1966
        if (CMS.API && CMS.API.Clipboard) {
6!
1967
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1968
        }
1969
    }, 0);
1970

1971
    $document
6✔
1972
        .off(Plugin.pointerUp)
1973
        .off(Plugin.keyDown)
1974
        .off(Plugin.keyUp)
1975
        .off(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin')
1976
        .on(Plugin.pointerUp, function() {
1977
            // call it as a static method, because otherwise we trigger it the
1978
            // amount of times CMS.Plugin is instantiated,
1979
            // which does not make much sense.
1980
            Plugin._hideSettingsMenu();
×
1981
        })
1982
        .on(Plugin.keyDown, function(e) {
1983
            if (e.keyCode === KEYS.SHIFT) {
26!
1984
                $document.data('expandmode', true);
×
1985
                try {
×
1986
                    $('.cms-plugin:hover').last().trigger('mouseenter');
×
1987
                    $('.cms-dragitem:hover').last().trigger('mouseenter');
×
1988
                } catch {}
1989
            }
1990
        })
1991
        .on(Plugin.keyUp, function(e) {
1992
            if (e.keyCode === KEYS.SHIFT) {
23!
1993
                $document.data('expandmode', false);
×
1994
                try {
×
1995
                    $(':hover').trigger('mouseleave');
×
1996
                } catch {}
1997
            }
1998
        })
1999
        .on(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin', function(e) {
2000
            var DOUBLECLICK_DELAY = 300;
×
2001

2002
            // prevents single click from messing up the edit call
2003
            // don't go to the link if there is custom js attached to it
2004
            // or if it's clicked along with shift, ctrl, cmd
2005
            if (e.shiftKey || e.ctrlKey || e.metaKey || e.isDefaultPrevented()) {
×
2006
                return;
×
2007
            }
2008
            e.preventDefault();
×
2009
            if (++clickCounter === 1) {
×
2010
                timer = setTimeout(function() {
×
2011
                    var anchor = $(e.target).closest('a');
×
2012

2013
                    clickCounter = 0;
×
2014
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
2015
                }, DOUBLECLICK_DELAY);
2016
            } else {
2017
                clearTimeout(timer);
×
2018
                clickCounter = 0;
×
2019
            }
2020
        });
2021

2022
    // have to delegate here because there might be plugins that
2023
    // have their content replaced by something dynamic. in case that tool
2024
    // copies the classes - double click to edit would still work
2025
    // also - do not try to highlight render_model_blocks, only actual plugins
2026
    $document.on(Plugin.click, '.cms-plugin:not([class*=cms-render-model])', Plugin._clickToHighlightHandler);
6✔
2027
    $document.on(`${Plugin.pointerOverAndOut} ${Plugin.touchStart}`, '.cms-plugin', function(e) {
6✔
2028
        // required for both, click and touch
2029
        // otherwise propagation won't work to the nested plugin
2030

2031
        e.stopPropagation();
×
2032
        const pluginContainer = $(e.target).closest('.cms-plugin:not(.cms-slot)');
×
2033
        const allOptions = pluginContainer.data('cms');
×
2034

2035
        if (!allOptions || !allOptions.length) {
×
2036
            return;
×
2037
        }
2038

2039
        const options = allOptions[0];
×
2040

2041
        if (e.type === 'touchstart') {
×
2042
            CMS.API.Tooltip._forceTouchOnce();
×
2043
        }
2044
        var name = options.plugin_name;
×
2045
        var id = options.plugin_id;
×
2046
        var type = options.type;
×
2047

2048
        if (type === 'generic') {
×
2049
            return;
×
2050
        }
2051
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
2052
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
2053

2054
        if (placeholder.length && placeholder.data('cms')) {
×
2055
            name = placeholder.data('cms').name + ': ' + name;
×
2056
        }
2057

2058
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2059
    });
2060

2061
    $document.on(Plugin.click, '.cms-dragarea-static .cms-dragbar', e => {
6✔
2062
        const placeholder = $(e.target).closest('.cms-dragarea');
×
2063

2064
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2065
            return;
×
2066
        }
2067

2068
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2069
    });
2070

2071
    $window.on('blur.cms', () => {
6✔
2072
        $document.data('expandmode', false);
6✔
2073
    });
2074
};
2075

2076
/**
2077
 * @method _isContainingMultiplePlugins
2078
 * @param {jQuery} node to check
2079
 * @static
2080
 * @private
2081
 * @returns {Boolean}
2082
 */
2083
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2084
    var currentData = node.data('cms');
130✔
2085

2086
    // istanbul ignore if
2087
    if (!currentData) {
130✔
2088
        throw new Error('Provided node is not a cms plugin.');
2089
    }
2090

2091
    var pluginIds = currentData.map(function(pluginData) {
130✔
2092
        return pluginData.plugin_id;
131✔
2093
    });
2094

2095
    if (pluginIds.length > 1) {
130✔
2096
        // another plugin already lives on the same node
2097
        // this only works because the plugins are rendered from
2098
        // the bottom to the top (leaf to root)
2099
        // meaning the deepest plugin is always first
2100
        return true;
1✔
2101
    }
2102

2103
    return false;
129✔
2104
};
2105

2106
/**
2107
 * Shows and immediately fades out a success notification (when
2108
 * plugin was successfully moved.
2109
 *
2110
 * @method _highlightPluginStructure
2111
 * @private
2112
 * @static
2113
 * @param {jQuery} el draggable element
2114
 */
2115

2116
Plugin._highlightPluginStructure = function _highlightPluginStructure(
1✔
2117
    el,
2118
    // eslint-disable-next-line no-magic-numbers
2119
    { successTimeout = 200, delay = 1500, seeThrough = false }
×
2120
) {
2121
    const tpl = $(`
×
2122
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
×
2123
        </div>
2124
    `);
2125

2126
    el.addClass('cms-draggable-success').append(tpl);
×
2127
    // start animation
2128
    if (successTimeout) {
×
2129
        setTimeout(() => {
×
2130
            tpl.fadeOut(successTimeout, function() {
×
2131
                $(this).remove();
×
2132
                el.removeClass('cms-draggable-success');
×
2133
            });
2134
        }, delay);
2135
    }
2136
    // make sure structurboard gets updated after success
2137
    $(Helpers._getWindow()).trigger('resize.sideframe');
×
2138
};
2139

2140
/**
2141
 * Highlights plugin in content mode
2142
 *
2143
 * @method _highlightPluginContent
2144
 * @private
2145
 * @static
2146
 * @param {String|Number} pluginId
2147
 */
2148
/* eslint-disable complexity, no-magic-numbers */
2149
Plugin._highlightPluginContent = function _highlightPluginContent(
1✔
2150
    pluginId,
2151
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
5✔
2152
) {
2153
    var coordinates = {};
1✔
2154
    var positions = [];
1✔
2155
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;
1✔
2156

2157
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2158
        var el = $(this);
1✔
2159
        var offset = el.offset();
1✔
2160
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2161
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2162
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2163
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2164
        var width = el.outerWidth();
1✔
2165
        var height = el.outerHeight();
1✔
2166

2167
        if (width === 0 && height === 0) {
1!
2168
            return;
×
2169
        }
2170

2171
        if (Number.isNaN(ml)) {
1!
2172
            ml = 0;
×
2173
        }
2174
        if (Number.isNaN(mr)) {
1!
2175
            mr = 0;
×
2176
        }
2177
        if (Number.isNaN(mt)) {
1!
2178
            mt = 0;
×
2179
        }
2180
        if (Number.isNaN(mb)) {
1!
2181
            mb = 0;
×
2182
        }
2183

2184
        positions.push({
1✔
2185
            x1: offset.left - ml,
2186
            x2: offset.left + width + mr,
2187
            y1: offset.top - mt,
2188
            y2: offset.top + height + mb
2189
        });
2190
    });
2191

2192
    if (positions.length === 0) {
1!
2193
        return;
×
2194
    }
2195

2196
    // turns out that offset calculation will be off by toolbar height if
2197
    // position is set to "relative" on html element.
2198
    var html = $('html');
1✔
2199
    var htmlMargin = html.css('position') === 'relative' ? parseInt($('html').css('margin-top'), 10) : 0;
1!
2200

2201
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2202
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2203
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2204
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2205

2206
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2207

2208
    $(
1✔
2209
        `
2210
        <div class="
2211
            cms-plugin-overlay
2212
            cms-dragitem-success
2213
            cms-plugin-overlay-${pluginId}
2214
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
1!
2215
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
1!
2216
        "
2217
            data-success-timeout="${successTimeout}"
2218
        >
2219
        </div>
2220
    `
2221
    )
2222
        .css(coordinates)
2223
        .css({
2224
            zIndex: 9999
2225
        })
2226
        .appendTo($('body'));
2227

2228
    if (successTimeout) {
1!
2229
        setTimeout(() => {
1✔
2230
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2231
                $(this).remove();
1✔
2232
            });
2233
        }, delay);
2234
    }
2235
};
2236

2237
Plugin._clickToHighlightHandler = function _clickToHighlightHandler() {
1✔
2238
    if (CMS.settings.mode !== 'structure') {
×
2239
        return;
×
2240
    }
2241
    // FIXME refactor into an object
2242
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true);
×
2243
};
2244

2245
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
2246
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2247
};
2248

2249
Plugin.aliasPluginDuplicatesMap = {};
1✔
2250
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2251

2252
// istanbul ignore next
2253
Plugin._initializeTree = function _initializeTree() {
2254
    const plugins = {};
2255

2256
    document.body.querySelectorAll(
2257
        'script[data-cms-plugin], ' +
2258
        'script[data-cms-placeholder], ' +
2259
        'script[data-cms-general]'
2260
    ).forEach(script => {
2261
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2262
    });
2263

2264
    CMS._plugins = Object.entries(plugins);
2265
    CMS._instances = CMS._plugins.map(function(args) {
2266
        return new CMS.Plugin(args[0], args[1]);
2267
    });
2268

2269
    // return the cms plugin instances just created
2270
    return CMS._instances;
2271
};
2272
/* eslint-enable complexity, no-magic-numbers */
2273

2274
Plugin._updateClipboard = function _updateClipboard() {
1✔
2275
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2276
};
2277

2278
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2279
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2280

2281
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2282

2283
    if (Helpers._isStorageSupported) {
2!
2284
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2285
    }
2286
};
2287

2288
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2289
    // this can't be cached since they are created and destroyed all over the place
2290
    $('.cms-add-plugin-placeholder').remove();
10✔
2291
};
2292

2293
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2294
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2295
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2296

2297
    // Re-read front-end editable fields ("general" plugins) from DOM
2298
    document.body.querySelectorAll('script[data-cms-general]').forEach(script => {
4✔
2299
        CMS._plugins.push([script.id, JSON.parse(script.textContent)]);
×
2300
    });
2301
    // Remove duplicates
2302
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2303

2304
    CMS._instances.forEach(instance => {
4✔
2305
        if (instance.options.type === 'placeholder') {
5✔
2306
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2307
            instance._ensureData();
2✔
2308
            instance.ui.container.data('cms', instance.options);
2✔
2309
            instance._setPlaceholder();
2✔
2310
        }
2311
    });
2312

2313
    CMS._instances.forEach(instance => {
4✔
2314
        if (instance.options.type === 'plugin') {
5✔
2315
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2316
            instance._ensureData();
2✔
2317
            instance.ui.container.data('cms').push(instance.options);
2✔
2318
            instance._setPluginContentEvents();
2✔
2319
        }
2320
    });
2321

2322
    CMS._plugins.forEach(([type, opts]) => {
4✔
2323
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2324
            const instance = CMS._instances.find(
8✔
2325
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2326
            );
2327

2328
            if (instance) {
8✔
2329
                // update
2330
                instance._setupUI(type);
1✔
2331
                instance._ensureData();
1✔
2332
                instance.ui.container.data('cms').push(instance.options);
1✔
2333
                instance._setGeneric();
1✔
2334
            } else {
2335
                // create
2336
                CMS._instances.push(new Plugin(type, opts));
7✔
2337
            }
2338
        }
2339
    });
2340
};
2341

2342
Plugin._getPluginById = function(id) {
1✔
2343
    return CMS._instances.find(({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2344
};
2345

2346
Plugin._updatePluginPositions = function(placeholder_id) {
1✔
2347
    // TODO can this be done in pure js? keep a tree model of the structure
2348
    // on the placeholder and update things there?
2349
    const plugins = $(`.cms-dragarea-${placeholder_id} .cms-draggable`).toArray();
10✔
2350

2351
    plugins.forEach((element, index) => {
10✔
2352
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2353
        const instance = Plugin._getPluginById(pluginId);
20✔
2354

2355
        if (!instance) {
20!
2356
            return;
20✔
2357
        }
2358

2359
        instance.options.position = index + 1;
×
2360
    });
2361
};
2362

2363
Plugin._recalculatePluginPositions = function(action, data) {
1✔
2364
    if (action === 'MOVE') {
×
2365
        // le sigh - recalculate all placeholders cause we don't know from where the
2366
        // plugin was moved from
2367
        CMS._instances.filter(({ options }) => options.type === 'placeholder')
×
2368
            .map(({ options }) => options.placeholder_id)
×
2369
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
2370
    } else if (data.placeholder_id) {
×
2371
        Plugin._updatePluginPositions(data.placeholder_id);
×
2372
    }
2373
};
2374

2375
// shorthand for jQuery(document).ready();
2376
$(Plugin._initializeGlobalHandlers);
1✔
2377

2378
export default Plugin;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc