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

divio / django-cms / #30499

03 Apr 2026 02:26PM UTC coverage: 90.223% (+14.4%) from 75.869%
#30499

push

travis-ci

web-flow
Merge 785db1343 into 8562357bf

1385 of 2210 branches covered (62.67%)

9533 of 10566 relevant lines covered (90.22%)

11.19 hits per line

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

59.23
/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
            plugin_parent_restriction: [],
54
            urls: {
55
                add_plugin: '',
56
                edit_plugin: '',
57
                move_plugin: '',
58
                copy_plugin: '',
59
                delete_plugin: ''
60
            }
61
        }, options);
62

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

221
        return itemContents;
137✔
222
    }
223

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

494
        if (clipboardDraggable.data('cms')) {
20!
495
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
496
            var type = clipboardPluginData.plugin_type;
20✔
497
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
20✔
498
                // special case when PlaceholderPlugin has a parent restriction named "0"
499
                return restriction !== '0';
20✔
500
            });
501
            var currentPluginType = this.options.plugin_type;
20✔
502

503
            if (
20✔
504
                (bounds.length && $.inArray(type, bounds) === -1) ||
60!
505
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
506
            ) {
507
                pasteItem.addClass('cms-submenu-item-disabled');
15✔
508
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
15✔
509
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
15✔
510
                return false;
15✔
511
            }
512
        } else {
513
            return false;
×
514
        }
515

516
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
517
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
518

519
        return true;
5✔
520
    }
521

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

542
        if (parent) {
4✔
543
            params.plugin_parent = parent;
2✔
544
        }
545
        var url = this.options.urls.add_plugin + '?' + $.param(params);
4✔
546

547
        const modal = new Modal({
4✔
548
            onClose: this.options.onClose || false,
7✔
549
            redirectOnClose: this.options.redirectOnClose || false
7✔
550
        });
551

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

570
            body.append(`<form method="post" action="${url}" style="display: none;">
1✔
571
                <input type="hidden" name="csrfmiddlewaretoken" value="${CMS.config.csrf}"></form>`);
572
            body.find('form').submit();
1✔
573
        }
574
        this.modal = modal;
4✔
575

576
        Helpers.removeEventListener('modal-closed.add-plugin');
4✔
577
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
4✔
578
            if (instance !== modal) {
1!
579
                return;
×
580
            }
581
            Plugin._removeAddPluginPlaceholder();
1✔
582
        });
583
    }
584

585
    _getPluginAddPosition() {
586
        if (this.options.type === 'placeholder') {
×
587
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
588
        }
589

590
        // assume plugin now
591
        // would prefer to get the information from the tree, but the problem is that the flat data
592
        // isn't sorted by position
593
        const maybeChildren = this.ui.draggable.find('.cms-draggable');
×
594

595
        if (maybeChildren.length) {
×
596
            const lastChild = maybeChildren.last();
×
597

598
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
599

600
            return lastChildInstance.options.position + 1;
×
601
        }
602

603
        return this.options.position + 1;
×
604
    }
605

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

622
        this.modal = modal;
3✔
623

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

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

652
    copyPlugin(opts, source_language) {
653
        // cancel request if already in progress
654
        if (CMS.API.locked) {
9✔
655
            return false;
1✔
656
        }
657
        CMS.API.locked = true;
8✔
658

659
        // set correct options (don't mutate them)
660
        var options = $.extend({}, opts || this.options);
8✔
661
        var sourceLanguage = source_language;
8✔
662
        let copyingFromLanguage = false;
8✔
663

664
        if (sourceLanguage) {
8✔
665
            copyingFromLanguage = true;
1✔
666
            options.target = options.placeholder_id;
1✔
667
            options.plugin_id = '';
1✔
668
            options.parent = '';
1✔
669
        } else {
670
            sourceLanguage = CMS.config.request.language;
7✔
671
        }
672

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

702
                // trigger error
703
                CMS.API.Messages.open({
3✔
704
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
705
                    error: true
706
                });
707
            }
708
        };
709

710
        $.ajax(request);
8✔
711
    }
712

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

727
        var that = this;
8✔
728
        var data = {
8✔
729
            placeholder_id: CMS.config.clipboard.id,
730
            plugin_id: this.options.plugin_id,
731
            plugin_parent: '',
732
            target_language: CMS.config.request.language,
733
            csrfmiddlewaretoken: CMS.config.csrf
734
        };
735

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

753
                // trigger error
754
                CMS.API.Messages.open({
3✔
755
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
756
                    error: true
757
                });
758
                hideLoader();
3✔
759
            }
760
        });
761
    }
762

763
    /**
764
     * Method is called when you click on the paste button on the plugin.
765
     * Uses existing solution of `copyPlugin(options)`
766
     *
767
     * @method pastePlugin
768
     */
769
    pastePlugin() {
770
        var id = this._getId(clipboardDraggable);
5✔
771
        var eventData = {
5✔
772
            id: id
773
        };
774

775
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
776

777
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
778
        if (this.options.plugin_id) {
5✔
779
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
780
        }
781
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
782
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
783
    }
784

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

804
        // set correct options
805
        const options = opts || this.options;
11✔
806

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

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

812
        // cancel here if we have no placeholder id
813
        if (placeholder_id === false) {
11✔
814
            return false;
1✔
815
        }
816
        const pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
817
        const plugin_parent = this._getId(pluginParentElement);
10✔
818

819
        // gather the data for ajax request
820
        const data = {
10✔
821
            plugin_id: options.plugin_id,
822
            plugin_parent: plugin_parent || '',
20✔
823
            target_language: CMS.config.request.language,
824
            csrfmiddlewaretoken: CMS.config.csrf,
825
            move_a_copy: options.move_a_copy
826
        };
827

828
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
829
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
830
        } else {
831
            data.placeholder_id = placeholder_id;
×
832

833
            Plugin._updatePluginPositions(placeholder_id);
×
834
            Plugin._updatePluginPositions(options.placeholder_id);
×
835
        }
836

837
        const position = this.options.position;
10✔
838

839
        data.target_position = position;
10✔
840

841
        showLoader();
10✔
842

843
        $.ajax({
10✔
844
            type: 'POST',
845
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
846
            data: data,
847
            success: response => {
848
                CMS.API.StructureBoard.invalidateState(
4✔
849
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
850
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
851
                );
852

853
                // enable actions again
854
                CMS.API.locked = false;
4✔
855
                hideLoader();
4✔
856
            },
857
            error: jqXHR => {
858
                CMS.API.locked = false;
4✔
859
                const msg = CMS.config.lang.error;
4✔
860

861
                // trigger error
862
                CMS.API.Messages.open({
4✔
863
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
864
                    error: true
865
                });
866
                hideLoader();
4✔
867
            }
868
        });
869
    }
870

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

884
        // set new setting on instance and plugin data
885
        this.options = settings;
×
886
        if (plugin.length) {
×
887
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
888
                return pluginData.plugin_id === settings.plugin_id;
×
889
            });
890

891
            plugin.each(function() {
×
892
                $(this).data('cms')[index] = settings;
×
893
            });
894
        }
895
        if (draggable.length) {
×
896
            draggable.data('cms', settings);
×
897
        }
898
    }
899

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

916
        this.modal = modal;
2✔
917

918
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
919
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
920
            if (instance === modal) {
5✔
921
                Plugin._removeAddPluginPlaceholder();
1✔
922
            }
923
        });
924
        modal.open({
2✔
925
            url: url,
926
            title: name,
927
            breadcrumbs: breadcrumb
928
        });
929
    }
930

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

942
        // close the plugin modal if it was open
943
        if (this.modal) {
2!
944
            this.modal.close();
×
945
            // unsubscribe to all the modal events
946
            this.modal.off();
×
947
        }
948

949
        if (mustCleanup) {
2✔
950
            this.cleanup();
1✔
951
        }
952

953
        // remove event bound to global elements like document or window
954
        $document.off(`.${this.uid}`);
2✔
955
        $window.off(`.${this.uid}`);
2✔
956
    }
957

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

971
    /**
972
     * Called after plugin is added through ajax.
973
     *
974
     * @method editPluginPostAjax
975
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
976
     * @param {Object} response response from server
977
     */
978
    editPluginPostAjax(toolbar, response) {
979
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
980
    }
981

982
    /**
983
     * _setSettingsMenu sets up event handlers for settings menu.
984
     *
985
     * @method _setSettingsMenu
986
     * @private
987
     * @param {jQuery} nav
988
     */
989
    _setSettingsMenu(nav) {
990
        var that = this;
153✔
991

992
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
153✔
993
        var dropdown = this.ui.dropdown;
153✔
994

995
        nav
153✔
996
            .off(Plugin.pointerUp)
997
            .on(Plugin.pointerUp, function(e) {
998
                e.preventDefault();
×
999
                e.stopPropagation();
×
1000
                var trigger = $(this);
×
1001

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

1017
        dropdown
153✔
1018
            .off(Plugin.mouseEvents)
1019
            .on(Plugin.mouseEvents, function(e) {
1020
                e.stopPropagation();
×
1021
            })
1022
            .off(Plugin.touchStart)
1023
            .on(Plugin.touchStart, function(e) {
1024
                // required for scrolling on mobile
1025
                e.stopPropagation();
×
1026
            });
1027

1028
        that._setupActions(nav);
153✔
1029
        // prevent propagation
1030
        nav
153✔
1031
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
1032
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1033
                e.stopPropagation();
×
1034
            });
1035

1036
        nav
153✔
1037
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
1038
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
1039
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1040
                e.stopPropagation();
×
1041
            });
1042
    }
1043

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

1067
        if (!isInViewport) {
3✔
1068
            scrollable.animate(
2✔
1069
                {
1070
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1071
                },
1072
                duration
1073
            );
1074
        }
1075
    }
1076

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

1096
        var initModal = once(function initModal() {
65✔
1097
            var placeholder = $(
×
1098
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1099
            );
1100
            var dragItem = nav.closest('.cms-dragitem');
×
1101
            var isPlaceholder = !dragItem.length;
×
1102
            var childrenList;
1103

1104
            modal = new Modal({
×
1105
                minWidth: 400,
1106
                minHeight: 400
1107
            });
1108

1109
            if (isPlaceholder) {
×
1110
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1111
            } else {
1112
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1113
            }
1114

1115
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
1116
                if (instance !== modal) {
×
1117
                    return;
×
1118
                }
1119

1120
                that._setupKeyboardTraversing();
×
1121
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
1122
                    that._toggleCollapsable(dragItem);
×
1123
                }
1124
                Plugin._removeAddPluginPlaceholder();
×
1125
                placeholder.appendTo(childrenList);
×
1126
                that._scrollToElement(placeholder);
×
1127
            });
1128

1129
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
1130
                if (instance !== modal) {
×
1131
                    return;
×
1132
                }
1133
                Plugin._removeAddPluginPlaceholder();
×
1134
            });
1135

1136
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
1137
                if (modal !== instance) {
×
1138
                    return;
×
1139
                }
1140
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1141

1142
                if (!isTouching) {
×
1143
                    // only focus the field if using mouse
1144
                    // otherwise keyboard pops up
1145
                    dropdown.find('input').trigger('focus');
×
1146
                }
1147
                isTouching = false;
×
1148
            });
1149

1150
            plugins = nav.siblings('.cms-plugin-picker');
×
1151

1152
            that._setupQuickSearch(plugins);
×
1153
        });
1154

1155
        nav
65✔
1156
            .on(Plugin.touchStart, function(e) {
1157
                isTouching = true;
×
1158
                // required on some touch devices so
1159
                // ui touch punch is not triggering mousemove
1160
                // which in turn results in pep triggering pointercancel
1161
                e.stopPropagation();
×
1162
            })
1163
            .on(Plugin.pointerUp, function(e) {
1164
                e.preventDefault();
×
1165
                e.stopPropagation();
×
1166

1167
                Plugin._hideSettingsMenu();
×
1168

1169
                possibleChildClasses = that._getPossibleChildClasses();
×
1170
                var selectionNeeded = possibleChildClasses.filter(':not(.cms-submenu-item-title)').length !== 1;
×
1171

1172
                if (selectionNeeded) {
×
1173
                    initModal();
×
1174

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

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

1198
                    that.addPlugin(pluginType, el.text(), parentId, showAddForm);
×
1199
                }
1200
            });
1201

1202
        // prevent propagation
1203
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
1204
            e.stopPropagation();
×
1205
        });
1206

1207
        nav
65✔
1208
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1209
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1210
                e.stopPropagation();
×
1211
            });
1212
    }
1213

1214
    _updateWithMostUsedPlugins(plugins) {
1215
        const items = plugins.find('.cms-submenu-item');
×
1216
        // eslint-disable-next-line no-unused-vars
1217
        const mostUsedPlugins = Object.entries(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
1218
        const MAX_MOST_USED_PLUGINS = 5;
×
1219
        let count = 0;
×
1220

1221
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
1222
            return plugins;
×
1223
        }
1224

1225
        let ref = plugins.find('.cms-quicksearch');
×
1226

1227
        mostUsedPlugins.forEach(([name]) => {
×
1228
            if (count === MAX_MOST_USED_PLUGINS) {
×
1229
                return;
×
1230
            }
1231
            const item = items.find(`[href=${name}]`);
×
1232

1233
            if (item.length) {
×
1234
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1235

1236
                ref.after(clone);
×
1237
                ref = clone;
×
1238
                count += 1;
×
1239
            }
1240
        });
1241

1242
        if (count) {
×
1243
            plugins.find('.cms-quicksearch').after(
×
1244
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1245
                    <span>${CMS.config.lang.mostUsed}</span>
1246
                </div>`)
1247
            );
1248
        }
1249

1250
        return plugins;
×
1251
    }
1252

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

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

1288
        if (childRestrictions && childRestrictions.length) {
33✔
1289
            resultElements = resultElements.filter(function() {
29✔
1290
                var item = $(this);
4,727✔
1291

1292
                return (
4,727✔
1293
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1294
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1295
                );
1296
            });
1297

1298
            resultElements = resultElements.filter(function(index) {
29✔
1299
                var item = $(this);
411✔
1300

1301
                return (
411✔
1302
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1303
                    (item.hasClass('cms-submenu-item-title') &&
1304
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1305
                            resultElements.eq(index + 1).length))
1306
                );
1307
            });
1308
        }
1309

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

1312
        return resultElements;
33✔
1313
    }
1314

1315
    /**
1316
     * Sets up event handlers for quicksearching in the plugin picker.
1317
     *
1318
     * @method _setupQuickSearch
1319
     * @private
1320
     * @param {jQuery} plugins plugins picker element
1321
     */
1322
    _setupQuickSearch(plugins) {
1323
        var that = this;
×
1324
        var FILTER_DEBOUNCE_TIMER = 100;
×
1325
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1326

1327
        var handler = debounce(function() {
×
1328
            var input = $(this);
×
1329
            // have to always find the pluginsPicker in the handler
1330
            // because of how we move things into/out of the modal
1331
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1332

1333
            that._filterPluginsList(pluginsPicker, input);
×
1334
        }, FILTER_DEBOUNCE_TIMER);
1335

1336
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1337
            Plugin.keyUp,
1338
            debounce(function(e) {
1339
                var input;
1340
                var pluginsPicker;
1341

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

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

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

1379
    /**
1380
     * Handler for the "action" items
1381
     *
1382
     * @method _delegate
1383
     * @param {$.Event} e event
1384
     * @private
1385
     */
1386
    // eslint-disable-next-line complexity
1387
    _delegate(e) {
1388
        e.preventDefault();
13✔
1389
        e.stopPropagation();
13✔
1390

1391
        var nav;
1392
        var that = this;
13✔
1393

1394
        if (e.data && e.data.nav) {
13!
1395
            nav = e.data.nav;
×
1396
        }
1397

1398
        // show loader and make sure scroll doesn't jump
1399
        showLoader();
13✔
1400

1401
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1402
        var el = $(e.target).closest(items);
13✔
1403

1404
        Plugin._hideSettingsMenu(nav);
13✔
1405

1406
        // set switch for subnav entries
1407
        switch (el.attr('data-rel')) {
13!
1408

1409
            case 'add': {
1410
                const pluginType = el.attr('href').replace('#', '');
2✔
1411
                const showAddForm = el.data('addForm');
2✔
1412

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

1462
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
×
1463
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
×
1464
                e.stopImmediatePropagation();
×
1465
                break;
×
1466
            default:
1467
                hideLoader();
2✔
1468
                CMS.API.Toolbar._delegate(el);
2✔
1469
        }
1470
    }
1471

1472
    /**
1473
     * Sets up keyboard traversing of plugin picker.
1474
     *
1475
     * @method _setupKeyboardTraversing
1476
     * @private
1477
     */
1478
    _setupKeyboardTraversing() {
1479
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1480
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1481

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

1492
            // bind arrow down and tab keys
1493
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1494
                e.preventDefault();
1495
                if (index >= 0 && index < anchors.length - 1) {
1496
                    anchors.eq(index + 1).focus();
1497
                } else {
1498
                    anchors.eq(0).focus();
1499
                }
1500
            }
1501

1502
            // bind arrow up and shift+tab keys
1503
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1504
                e.preventDefault();
1505
                if (anchors.is(':focus')) {
1506
                    anchors.eq(index - 1).focus();
1507
                } else {
1508
                    anchors.eq(anchors.length).focus();
1509
                }
1510
            }
1511
        });
1512
    }
1513

1514
    /**
1515
     * Opens the settings menu for a plugin.
1516
     *
1517
     * @method _showSettingsMenu
1518
     * @private
1519
     * @param {jQuery} nav trigger element
1520
     */
1521
    _showSettingsMenu(nav) {
1522
        this._checkIfPasteAllowed();
×
1523

1524
        var dropdown = this.ui.dropdown;
×
1525
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
1526
        var MIN_SCREEN_MARGIN = 10;
×
1527

1528
        nav.addClass('cms-btn-active');
×
1529
        parents.addClass('cms-z-index-9999');
×
1530

1531
        // set visible states
1532
        dropdown.show();
×
1533

1534
        // calculate dropdown positioning
1535
        if (
×
1536
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1537
            nav.offset().top - dropdown.height() >= 0
1538
        ) {
1539
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1540
        } else {
1541
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1542
        }
1543
    }
1544

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

1559
        // cancel if query is zero
1560
        if (query === '') {
5✔
1561
            items.add(titles).show();
1✔
1562
            return false;
1✔
1563
        }
1564

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

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

1569
        // Simple case-insensitive substring matching (replaces fuzzyFilter)
1570
        var queryLower = query.toLowerCase();
4✔
1571

1572
        items.hide();
4✔
1573
        items.each(function() {
4✔
1574
            var item = $(this);
72✔
1575
            var text = item.text().toLowerCase();
72✔
1576

1577
            if (text.indexOf(queryLower) !== -1) {
72✔
1578
                item.show();
3✔
1579
            }
1580
        });
1581

1582
        // check if a title is matching
1583
        titles.filter(':visible').each(function(index, item) {
4✔
1584
            titles.hide();
1✔
1585
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1586
        });
1587

1588
        // always display title of a category
1589
        items.filter(':visible').each(function(index, titleItem) {
4✔
1590
            var item = $(titleItem);
16✔
1591

1592
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1593
                item.prev().show();
2✔
1594
            } else {
1595
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1596
            }
1597
        });
1598

1599
        mostRecentItems.hide();
4✔
1600
    }
1601

1602
    /**
1603
     * Toggles collapsable item.
1604
     *
1605
     * @method _toggleCollapsable
1606
     * @private
1607
     * @param {jQuery} el element to toggle
1608
     * @returns {Boolean|void}
1609
     */
1610
    _toggleCollapsable(el) {
1611
        var that = this;
×
1612
        var id = that._getId(el.parent());
×
1613
        var draggable = el.closest('.cms-draggable');
×
1614
        var items;
1615

1616
        var settings = CMS.settings;
×
1617

1618
        settings.states = settings.states || [];
×
1619

1620
        if (!draggable || !draggable.length) {
×
1621
            return;
×
1622
        }
1623

1624
        // collapsable function and save states
1625
        if (el.hasClass('cms-dragitem-expanded')) {
×
1626
            settings.states.splice($.inArray(id, settings.states), 1);
×
1627
            el
×
1628
                .removeClass('cms-dragitem-expanded')
1629
                .parent()
1630
                .find('> .cms-collapsable-container')
1631
                .addClass('cms-hidden');
1632

1633
            if ($document.data('expandmode')) {
×
1634
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1635
                if (!items.length) {
×
1636
                    return false;
×
1637
                }
1638
                items.each(function() {
×
1639
                    var item = $(this);
×
1640

1641
                    if (item.hasClass('cms-dragitem-expanded')) {
×
1642
                        that._toggleCollapsable(item);
×
1643
                    }
1644
                });
1645
            }
1646
        } else {
1647
            settings.states.push(id);
×
1648
            el
×
1649
                .addClass('cms-dragitem-expanded')
1650
                .parent()
1651
                .find('> .cms-collapsable-container')
1652
                .removeClass('cms-hidden');
1653

1654
            if ($document.data('expandmode')) {
×
1655
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1656
                if (!items.length) {
×
1657
                    return false;
×
1658
                }
1659
                items.each(function() {
×
1660
                    var item = $(this);
×
1661

1662
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1663
                        that._toggleCollapsable(item);
×
1664
                    }
1665
                });
1666
            }
1667
        }
1668

1669
        this._updatePlaceholderCollapseState();
×
1670

1671
        // make sure structurboard gets updated after expanding
1672
        $document.trigger('resize.sideframe');
×
1673

1674
        // save settings
1675
        Helpers.setSettings(settings);
×
1676
    }
1677

1678
    _updatePlaceholderCollapseState() {
1679
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1680
            return;
×
1681
        }
1682

1683
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1684
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1685
            .map(([, o]) => o.plugin_id);
×
1686

1687
        const openedPlugins = CMS.settings.states;
×
1688
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
1689
        const areAllRemainingPluginsLeafs = closedPlugins.every(id => {
×
1690
            return !CMS._plugins.find(
×
1691
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1692
            );
1693
        });
1694
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
1695
        var settings = CMS.settings;
×
1696

1697
        if (areAllRemainingPluginsLeafs) {
×
1698
            // meaning that all plugins in current placeholder are expanded
1699
            el.addClass('cms-dragbar-title-expanded');
×
1700

1701
            settings.dragbars = settings.dragbars || [];
×
1702
            settings.dragbars.push(this.options.placeholder_id);
×
1703
        } else {
1704
            el.removeClass('cms-dragbar-title-expanded');
×
1705

1706
            settings.dragbars = settings.dragbars || [];
×
1707
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1708
        }
1709
    }
1710

1711
    /**
1712
     * Sets up collabspable event handlers.
1713
     *
1714
     * @method _collapsables
1715
     * @private
1716
     * @returns {Boolean|void}
1717
     */
1718
    _collapsables() {
1719
        // one time setup
1720
        var that = this;
153✔
1721

1722
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
153✔
1723
        // cancel here if its not a draggable
1724
        if (!this.ui.draggable.length) {
153✔
1725
            return false;
38✔
1726
        }
1727

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

1730
        // check which button should be shown for collapsemenu
1731
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
115✔
1732
        var open = els.filter('.cms-dragitem-expanded');
115✔
1733

1734
        if (els.length === open.length && els.length + open.length !== 0) {
115!
1735
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1736
        }
1737

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

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

1764
        // cancel if there are no items
1765
        if (!items.length) {
×
1766
            return false;
×
1767
        }
1768
        items.each(function() {
×
1769
            var item = $(this);
×
1770

1771
            if (!item.hasClass('cms-dragitem-expanded')) {
×
1772
                that._toggleCollapsable(item);
×
1773
            }
1774
        });
1775

1776
        el.addClass('cms-dragbar-title-expanded');
×
1777

1778
        var settings = CMS.settings;
×
1779

1780
        settings.dragbars = settings.dragbars || [];
×
1781
        settings.dragbars.push(this.options.placeholder_id);
×
1782
        Helpers.setSettings(settings);
×
1783
    }
1784

1785
    /**
1786
     * Collapses all the collapsables in the given placeholder.
1787
     *
1788
     * @method _collapseAll
1789
     * @private
1790
     * @param {jQuery} el trigger element that is a child of a placeholder
1791
     */
1792
    _collapseAll(el) {
1793
        var that = this;
×
1794
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1795

1796
        items.each(function() {
×
1797
            var item = $(this);
×
1798

1799
            if (item.hasClass('cms-dragitem-expanded')) {
×
1800
                that._toggleCollapsable(item);
×
1801
            }
1802
        });
1803

1804
        el.removeClass('cms-dragbar-title-expanded');
×
1805

1806
        var settings = CMS.settings;
×
1807

1808
        settings.dragbars = settings.dragbars || [];
×
1809
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1810
        Helpers.setSettings(settings);
×
1811
    }
1812

1813
    /**
1814
     * Gets the id of the element, uses CMS.StructureBoard instance.
1815
     *
1816
     * @method _getId
1817
     * @private
1818
     * @param {jQuery} el element to get id from
1819
     * @returns {String}
1820
     */
1821
    _getId(el) {
1822
        return CMS.API.StructureBoard.getId(el);
36✔
1823
    }
1824

1825
    /**
1826
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1827
     *
1828
     * @method _getIds
1829
     * @private
1830
     * @param {jQuery} els elements to get id from
1831
     * @returns {String[]}
1832
     */
1833
    _getIds(els) {
1834
        return CMS.API.StructureBoard.getIds(els);
×
1835
    }
1836

1837
    /**
1838
     * Traverses the registry to find plugin parents
1839
     *
1840
     * @method _getPluginBreadcrumbs
1841
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1842
     * @private
1843
     */
1844
    _getPluginBreadcrumbs() {
1845
        var breadcrumbs = [];
6✔
1846

1847
        breadcrumbs.unshift({
6✔
1848
            title: this.options.plugin_name,
1849
            url: this.options.urls.edit_plugin
1850
        });
1851

1852
        var findParentPlugin = function(id) {
6✔
1853
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1854
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1855
            })[0];
1856
        };
1857

1858
        var id = this.options.plugin_parent;
6✔
1859
        var data;
1860

1861
        while (id && id !== 'None') {
6✔
1862
            data = findParentPlugin(id);
6✔
1863

1864
            if (!data) {
6✔
1865
                break;
1✔
1866
            }
1867

1868
            breadcrumbs.unshift({
5✔
1869
                title: data[1].plugin_name,
1870
                url: data[1].urls.edit_plugin
1871
            });
1872
            id = data[1].plugin_parent;
5✔
1873
        }
1874

1875
        return breadcrumbs;
6✔
1876
    }
1877
}
1878

1879
// Static event names
1880
Plugin.click = 'click.cms.plugin';
1✔
1881
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1882
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1883
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1884
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1885
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1886
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1887
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1888
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1889
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1890

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

1905
        if (pluginIndex === -1) {
×
1906
            CMS._plugins.push([pluginContainer, pluginData]);
×
1907
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1908
        } else {
1909
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
1910
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
1911
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1912
        }
1913
    });
1914
};
1915

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

1927
    if (!nav.length) {
20!
1928
        return;
20✔
1929
    }
1930
    nav.removeClass('cms-btn-active');
×
1931

1932
    // set correct active state
1933
    nav.closest('.cms-draggable').data('active', false);
×
1934
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1935

1936
    nav.siblings('.cms-submenu-dropdown').hide();
×
1937
    nav.siblings('.cms-quicksearch').hide();
×
1938
    // reset search
1939
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1940

1941
    // reset relativity
1942
    $('.cms-dragbar').css('position', '');
×
1943
};
1944

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

1959
    Plugin._updateClipboard();
6✔
1960

1961
    // Structureboard initialized too late
1962
    setTimeout(function() {
6✔
1963
        var pluginData = {};
6✔
1964
        var html = '';
6✔
1965

1966
        if (clipboardDraggable.length) {
6✔
1967
            pluginData = CMS._plugins.find(
5✔
1968
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1969
            )[1];
1970
            html = clipboardDraggable.parent().html();
5✔
1971
        }
1972
        if (CMS.API && CMS.API.Clipboard) {
6!
1973
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1974
        }
1975
    }, 0);
1976

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

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

2019
                    clickCounter = 0;
×
2020
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
2021
                }, DOUBLECLICK_DELAY);
2022
            } else {
2023
                clearTimeout(timer);
×
2024
                clickCounter = 0;
×
2025
            }
2026
        });
2027

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

2037
        e.stopPropagation();
×
2038
        const pluginContainer = $(e.target).closest('.cms-plugin:not(.cms-slot)');
×
2039
        const allOptions = pluginContainer.data('cms');
×
2040

2041
        if (!allOptions || !allOptions.length) {
×
2042
            return;
×
2043
        }
2044

2045
        const options = allOptions[0];
×
2046

2047
        if (e.type === 'touchstart') {
×
2048
            CMS.API.Tooltip._forceTouchOnce();
×
2049
        }
2050
        var name = options.plugin_name;
×
2051
        var id = options.plugin_id;
×
2052
        var type = options.type;
×
2053

2054
        if (type === 'generic') {
×
2055
            return;
×
2056
        }
2057
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
2058
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
2059

2060
        if (placeholder.length && placeholder.data('cms')) {
×
2061
            name = placeholder.data('cms').name + ': ' + name;
×
2062
        }
2063

2064
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
2065
    });
2066

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

2070
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2071
            return;
×
2072
        }
2073

2074
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2075
    });
2076

2077
    $window.on('blur.cms', () => {
6✔
2078
        $document.data('expandmode', false);
6✔
2079
    });
2080
};
2081

2082
/**
2083
 * @method _isContainingMultiplePlugins
2084
 * @param {jQuery} node to check
2085
 * @static
2086
 * @private
2087
 * @returns {Boolean}
2088
 */
2089
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2090
    var currentData = node.data('cms');
130✔
2091

2092
    // istanbul ignore if
2093
    if (!currentData) {
130✔
2094
        throw new Error('Provided node is not a cms plugin.');
2095
    }
2096

2097
    var pluginIds = currentData.map(function(pluginData) {
130✔
2098
        return pluginData.plugin_id;
131✔
2099
    });
2100

2101
    if (pluginIds.length > 1) {
130✔
2102
        // another plugin already lives on the same node
2103
        // this only works because the plugins are rendered from
2104
        // the bottom to the top (leaf to root)
2105
        // meaning the deepest plugin is always first
2106
        return true;
1✔
2107
    }
2108

2109
    return false;
129✔
2110
};
2111

2112
/**
2113
 * Shows and immediately fades out a success notification (when
2114
 * plugin was successfully moved.
2115
 *
2116
 * @method _highlightPluginStructure
2117
 * @private
2118
 * @static
2119
 * @param {jQuery} el draggable element
2120
 */
2121

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

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

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

2163
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2164
        var el = $(this);
1✔
2165
        var offset = el.offset();
1✔
2166
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2167
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2168
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2169
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2170
        var width = el.outerWidth();
1✔
2171
        var height = el.outerHeight();
1✔
2172

2173
        if (width === 0 && height === 0) {
1!
2174
            return;
×
2175
        }
2176

2177
        if (Number.isNaN(ml)) {
1!
2178
            ml = 0;
×
2179
        }
2180
        if (Number.isNaN(mr)) {
1!
2181
            mr = 0;
×
2182
        }
2183
        if (Number.isNaN(mt)) {
1!
2184
            mt = 0;
×
2185
        }
2186
        if (Number.isNaN(mb)) {
1!
2187
            mb = 0;
×
2188
        }
2189

2190
        positions.push({
1✔
2191
            x1: offset.left - ml,
2192
            x2: offset.left + width + mr,
2193
            y1: offset.top - mt,
2194
            y2: offset.top + height + mb
2195
        });
2196
    });
2197

2198
    if (positions.length === 0) {
1!
2199
        return;
×
2200
    }
2201

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

2207
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2208
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2209
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2210
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2211

2212
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2213

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

2234
    if (successTimeout) {
1!
2235
        setTimeout(() => {
1✔
2236
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2237
                $(this).remove();
1✔
2238
            });
2239
        }, delay);
2240
    }
2241
};
2242

2243
Plugin._clickToHighlightHandler = function _clickToHighlightHandler() {
1✔
2244
    if (CMS.settings.mode !== 'structure') {
×
2245
        return;
×
2246
    }
2247
    // FIXME refactor into an object
2248
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true);
×
2249
};
2250

2251
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
2252
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2253
};
2254

2255
Plugin.aliasPluginDuplicatesMap = {};
1✔
2256
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2257

2258
// istanbul ignore next
2259
Plugin._initializeTree = function _initializeTree() {
2260
    const plugins = {};
2261

2262
    document.body.querySelectorAll(
2263
        'script[data-cms-plugin], ' +
2264
        'script[data-cms-placeholder], ' +
2265
        'script[data-cms-general]'
2266
    ).forEach(script => {
2267
        plugins[script.id] = JSON.parse(script.textContent || '{}');
2268
    });
2269

2270
    CMS._plugins = Object.entries(plugins);
2271
    CMS._instances = CMS._plugins.map(function(args) {
2272
        return new CMS.Plugin(args[0], args[1]);
2273
    });
2274

2275
    // return the cms plugin instances just created
2276
    return CMS._instances;
2277
};
2278
/* eslint-enable complexity, no-magic-numbers */
2279

2280
Plugin._updateClipboard = function _updateClipboard() {
1✔
2281
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2282
};
2283

2284
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2285
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2286

2287
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2288

2289
    if (Helpers._isStorageSupported) {
2!
2290
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2291
    }
2292
};
2293

2294
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2295
    // this can't be cached since they are created and destroyed all over the place
2296
    $('.cms-add-plugin-placeholder').remove();
10✔
2297
};
2298

2299
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2300
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2301
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2302

2303
    // Re-read front-end editable fields ("general" plugins) from DOM
2304
    document.body.querySelectorAll('script[data-cms-general]').forEach(script => {
4✔
2305
        CMS._plugins.push([script.id, JSON.parse(script.textContent)]);
×
2306
    });
2307
    // Remove duplicates
2308
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2309

2310
    CMS._instances.forEach(instance => {
4✔
2311
        if (instance.options.type === 'placeholder') {
5✔
2312
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2313
            instance._ensureData();
2✔
2314
            instance.ui.container.data('cms', instance.options);
2✔
2315
            instance._setPlaceholder();
2✔
2316
        }
2317
    });
2318

2319
    CMS._instances.forEach(instance => {
4✔
2320
        if (instance.options.type === 'plugin') {
5✔
2321
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2322
            instance._ensureData();
2✔
2323
            instance.ui.container.data('cms').push(instance.options);
2✔
2324
            instance._setPluginContentEvents();
2✔
2325
        }
2326
    });
2327

2328
    CMS._plugins.forEach(([type, opts]) => {
4✔
2329
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2330
            const instance = CMS._instances.find(
8✔
2331
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2332
            );
2333

2334
            if (instance) {
8✔
2335
                // update
2336
                instance._setupUI(type);
1✔
2337
                instance._ensureData();
1✔
2338
                instance.ui.container.data('cms').push(instance.options);
1✔
2339
                instance._setGeneric();
1✔
2340
            } else {
2341
                // create
2342
                CMS._instances.push(new Plugin(type, opts));
7✔
2343
            }
2344
        }
2345
    });
2346
};
2347

2348
Plugin._getPluginById = function(id) {
1✔
2349
    return CMS._instances.find(({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2350
};
2351

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

2357
    plugins.forEach((element, index) => {
10✔
2358
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2359
        const instance = Plugin._getPluginById(pluginId);
20✔
2360

2361
        if (!instance) {
20!
2362
            return;
20✔
2363
        }
2364

2365
        instance.options.position = index + 1;
×
2366
    });
2367
};
2368

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

2381
// shorthand for jQuery(document).ready();
2382
$(Plugin._initializeGlobalHandlers);
1✔
2383

2384
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