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

divio / django-cms / #28819

10 May 2024 04:50PM UTC coverage: 76.44% (-0.05%) from 76.494%
#28819

push

travis-ci

web-flow
Merge baff66ec5 into 0b3ad4fe0

1048 of 1551 branches covered (67.57%)

0 of 3 new or added lines in 1 file covered. (0.0%)

8 existing lines in 1 file now uncovered.

2521 of 3298 relevant lines covered (76.44%)

32.37 hits per line

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

59.31
/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 '../polyfills/array.prototype.findindex';
8
import nextUntil from './nextuntil';
9

10
import { toPairs, filter, isNaN, debounce, findIndex, find, every, uniqWith, once, difference, isEqual } from 'lodash';
11

12
import Class from 'classjs';
13
import { Helpers, KEYS, $window, $document, uid } from './cms.base';
14
import { showLoader, hideLoader } from './loader';
15
import { filter as fuzzyFilter } from 'fuzzaldrin';
16

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

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

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

31
/**
32
 * Class for handling Plugins / Placeholders or Generics.
33
 * Handles adding / moving / copying / pasting / menus etc
34
 * in structureboard.
35
 *
36
 * @class Plugin
37
 * @namespace CMS
38
 * @uses CMS.API.Helpers
39
 */
40
var Plugin = new Class({
1✔
41
    implement: [Helpers],
42

43
    options: {
44
        type: '', // bar, plugin or generic
45
        placeholder_id: null,
46
        plugin_type: '',
47
        plugin_id: null,
48
        plugin_parent: null,
49
        plugin_restriction: [],
50
        plugin_parent_restriction: [],
51
        urls: {
52
            add_plugin: '',
53
            edit_plugin: '',
54
            move_plugin: '',
55
            copy_plugin: '',
56
            delete_plugin: ''
57
        }
58
    },
59

60
    // these properties will be filled later
61
    modal: null,
62

63
    initialize: function initialize(container, options) {
64
        this.options = $.extend(true, {}, this.options, options);
178✔
65

66
        // create an unique for this component to use it internally
67
        this.uid = uid();
178✔
68

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

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

79
        // determine type of plugin
80
        switch (this.options.type) {
176✔
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);
129✔
91
                Plugin.aliasPluginDuplicatesMap[this.options.plugin_id] = true;
129✔
92
                this._setPlugin();
129✔
93
                if (isStructureReady()) {
129!
94
                    this._collapsables();
129✔
95
                }
96
                break;
129✔
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: function _ensureData() {
105
        // bind data element to the container (mutating!)
106
        if (!this.ui.container.data('cms')) {
178✔
107
            this.ui.container.data('cms', []);
173✔
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: function setupUI(container) {
120
        var wrapper = $(`.${container}`);
178✔
121
        var 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/)) {
178✔
126
            // so it's possible that multiple plugins (more often generics) are rendered
127
            // in different places. e.g. page menu in the header and in the footer
128
            // so first, we find all the template tags, then put them in a structure like this:
129
            // [[start, end], [start, end]...]
130
            //
131
            // in case of plugins it means that it's aliased plugin or a plugin in a duplicated
132
            // static placeholder (for whatever reason)
133
            var contentWrappers = wrapper.toArray().reduce((wrappers, elem, index) => {
135✔
134
                if (index === 0) {
272✔
135
                    wrappers[0].push(elem);
135✔
136
                    return wrappers;
135✔
137
                }
138

139
                var lastWrapper = wrappers[wrappers.length - 1];
137✔
140
                var lastItemInWrapper = lastWrapper[lastWrapper.length - 1];
137✔
141

142
                if ($(lastItemInWrapper).is('.cms-plugin-end')) {
137✔
143
                    wrappers.push([elem]);
1✔
144
                } else {
145
                    lastWrapper.push(elem);
136✔
146
                }
147

148
                return wrappers;
137✔
149
            }, [[]]);
150

151
            // then we map that structure into an array of jquery collections
152
            // from which we filter out empty ones
153
            contents = contentWrappers
135✔
154
                .map(items => {
155
                    var templateStart = $(items[0]);
136✔
156
                    var className = templateStart.attr('class').replace('cms-plugin-start', '');
136✔
157

158
                    var itemContents = $(nextUntil(templateStart[0], container));
136✔
159

160
                    $(items).filter('template').remove();
136✔
161

162
                    itemContents.each((index, el) => {
136✔
163
                        // if it's a non-space top-level text node - wrap it in `cms-plugin`
164
                        if (el.nodeType === Node.TEXT_NODE && !el.textContent.match(/^\s*$/)) {
157✔
165
                            var element = $(el);
10✔
166

167
                            element.wrap('<cms-plugin class="cms-plugin-text-node"></cms-plugin>');
10✔
168
                            itemContents[index] = element.parent()[0];
10✔
169
                        }
170
                    });
171

172
                    // otherwise we don't really need text nodes or comment nodes or empty text nodes
173
                    itemContents = itemContents.filter(function() {
136✔
174
                        return this.nodeType !== Node.TEXT_NODE && this.nodeType !== Node.COMMENT_NODE;
157✔
175
                    });
176

177
                    itemContents.addClass(`cms-plugin ${className}`);
136✔
178

179
                    return itemContents;
136✔
180
                })
181
                .filter(v => v.length);
136✔
182

183
            if (contents.length) {
135!
184
                // and then reduce it to one big collection
185
                contents = contents.reduce((collection, items) => collection.add(items), $());
136✔
186
            }
187
        } else {
188
            contents = wrapper;
43✔
189
        }
190

191
        // in clipboard can be non-existent
192
        if (!contents.length) {
178✔
193
            contents = $('<div></div>');
11✔
194
        }
195

196
        this.ui = this.ui || {};
178✔
197
        this.ui.container = contents;
178✔
198
    },
199

200
    /**
201
     * Sets up behaviours and ui for placeholder.
202
     *
203
     * @method _setPlaceholder
204
     * @private
205
     */
206
    _setPlaceholder: function() {
207
        var that = this;
23✔
208

209
        this.ui.dragbar = $('.cms-dragbar-' + this.options.placeholder_id);
23✔
210
        this.ui.draggables = this.ui.dragbar.closest('.cms-dragarea').find('> .cms-draggables');
23✔
211
        this.ui.submenu = this.ui.dragbar.find('.cms-submenu-settings');
23✔
212
        var title = this.ui.dragbar.find('.cms-dragbar-title');
23✔
213
        var togglerLinks = this.ui.dragbar.find('.cms-dragbar-toggler a');
23✔
214
        var expanded = 'cms-dragbar-title-expanded';
23✔
215

216
        // register the subnav on the placeholder
217
        this._setSettingsMenu(this.ui.submenu);
23✔
218
        this._setAddPluginModal(this.ui.dragbar.find('.cms-submenu-add'));
23✔
219

220
        // istanbul ignore next
221
        CMS.settings.dragbars = CMS.settings.dragbars || []; // expanded dragbars array
222

223
        // enable expanding/collapsing globally within the placeholder
224
        togglerLinks.off(Plugin.click).on(Plugin.click, function(e) {
23✔
225
            e.preventDefault();
×
226
            if (title.hasClass(expanded)) {
×
227
                that._collapseAll(title);
×
228
            } else {
229
                that._expandAll(title);
×
230
            }
231
        });
232

233
        if ($.inArray(this.options.placeholder_id, CMS.settings.dragbars) !== -1) {
23!
234
            title.addClass(expanded);
×
235
        }
236

237
        this._checkIfPasteAllowed();
23✔
238
    },
239

240
    /**
241
     * Sets up behaviours and ui for plugin.
242
     *
243
     * @method _setPlugin
244
     * @private
245
     */
246
    _setPlugin: function() {
247
        if (isStructureReady()) {
129!
248
            this._setPluginStructureEvents();
129✔
249
        }
250
        if (isContentReady()) {
129!
251
            this._setPluginContentEvents();
129✔
252
        }
253
    },
254

255
    _setPluginStructureEvents: function _setPluginStructureEvents() {
256
        var that = this;
129✔
257

258
        // filling up ui object
259
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
129✔
260
        this.ui.dragitem = this.ui.draggable.find('> .cms-dragitem');
129✔
261
        this.ui.draggables = this.ui.draggable.find('> .cms-draggables');
129✔
262
        this.ui.submenu = this.ui.dragitem.find('.cms-submenu');
129✔
263

264
        this.ui.draggable.data('cms', this.options);
129✔
265

266
        this.ui.dragitem.on(Plugin.doubleClick, this._dblClickToEditHandler.bind(this));
129✔
267

268
        // adds listener for all plugin updates
269
        this.ui.draggable.off('cms-plugins-update').on('cms-plugins-update', function(e, eventData) {
129✔
270
            e.stopPropagation();
×
271
            that.movePlugin(null, eventData);
×
272
        });
273

274
        // adds listener for copy/paste updates
275
        this.ui.draggable.off('cms-paste-plugin-update').on('cms-paste-plugin-update', function(e, eventData) {
129✔
276
            e.stopPropagation();
5✔
277

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

280
            // find out new placeholder id
281
            var placeholder_id = that._getId(dragitem.closest('.cms-dragarea'));
5✔
282

283
            // if placeholder_id is empty, cancel
284
            if (!placeholder_id) {
5!
285
                return false;
×
286
            }
287

288
            var data = dragitem.data('cms');
5✔
289

290
            data.target = placeholder_id;
5✔
291
            data.parent = that._getId(dragitem.parent().closest('.cms-draggable'));
5✔
292
            data.move_a_copy = true;
5✔
293

294
            // expand the plugin we paste to
295
            CMS.settings.states.push(data.parent);
5✔
296
            Helpers.setSettings(CMS.settings);
5✔
297

298
            that.movePlugin(data);
5✔
299
        });
300

301
        setTimeout(() => {
129✔
302
            this.ui.dragitem
129✔
303
                .on('mouseenter', e => {
304
                    e.stopPropagation();
×
305
                    if (!$document.data('expandmode')) {
×
306
                        return;
×
307
                    }
308
                    if (this.ui.draggable.find('> .cms-dragitem > .cms-plugin-disabled').length) {
×
309
                        return;
×
310
                    }
311
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
312
                        return;
×
313
                    }
314
                    if (CMS.API.StructureBoard.dragging) {
×
315
                        return;
×
316
                    }
317
                    // eslint-disable-next-line no-magic-numbers
318
                    Plugin._highlightPluginContent(this.options.plugin_id, { successTimeout: 0, seeThrough: true });
×
319
                })
320
                .on('mouseleave', e => {
321
                    if (!CMS.API.StructureBoard.ui.container.hasClass('cms-structure-condensed')) {
×
322
                        return;
×
323
                    }
324
                    e.stopPropagation();
×
325
                    // eslint-disable-next-line no-magic-numbers
326
                    Plugin._removeHighlightPluginContent(this.options.plugin_id);
×
327
                });
328
            // attach event to the plugin menu
329
            this._setSettingsMenu(this.ui.submenu);
129✔
330

331
            // attach events for the "Add plugin" modal
332
            this._setAddPluginModal(this.ui.dragitem.find('.cms-submenu-add'));
129✔
333

334
            // clickability of "Paste" menu item
335
            this._checkIfPasteAllowed();
129✔
336
        });
337
    },
338

339
    _dblClickToEditHandler: function _dblClickToEditHandler(e) {
340
        var that = this;
×
NEW
341
        var disabled = $(e.currentTarget).closest('.cms-drag-disabled');
×
342
        e.preventDefault();
×
343
        e.stopPropagation();
×
344

NEW
345
        if (!disabled.length) {
×
NEW
346
            that.editPlugin(
×
347
                Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
348
                that.options.plugin_name,
349
                that._getPluginBreadcrumbs()
350
            );
351
        }
352
    },
353

354
    _setPluginContentEvents: function _setPluginContentEvents() {
355
        const pluginDoubleClickEvent = this._getNamepacedEvent(Plugin.doubleClick);
129✔
356

357
        this.ui.container
129✔
358
            .off('mouseover.cms.plugins')
359
            .on('mouseover.cms.plugins', e => {
360
                if (!$document.data('expandmode')) {
×
361
                    return;
×
362
                }
363
                if (CMS.settings.mode !== 'structure') {
×
364
                    return;
×
365
                }
366
                e.stopPropagation();
×
367
                $('.cms-dragitem-success').remove();
×
368
                $('.cms-draggable-success').removeClass('cms-draggable-success');
×
369
                CMS.API.StructureBoard._showAndHighlightPlugin(0, true); // eslint-disable-line no-magic-numbers
×
370
            })
371
            .off('mouseout.cms.plugins')
372
            .on('mouseout.cms.plugins', e => {
373
                if (CMS.settings.mode !== 'structure') {
×
374
                    return;
×
375
                }
376
                e.stopPropagation();
×
377
                if (this.ui.draggable && this.ui.draggable.length) {
×
378
                    this.ui.draggable.find('.cms-dragitem-success').remove();
×
379
                    this.ui.draggable.removeClass('cms-draggable-success');
×
380
                }
381
                // Plugin._removeHighlightPluginContent(this.options.plugin_id);
382
            });
383

384
        if (!Plugin._isContainingMultiplePlugins(this.ui.container)) {
129✔
385
            $document
128✔
386
                .off(pluginDoubleClickEvent, `.cms-plugin-${this.options.plugin_id}`)
387
                .on(
388
                    pluginDoubleClickEvent,
389
                    `.cms-plugin-${this.options.plugin_id}`,
390
                    this._dblClickToEditHandler.bind(this)
391
                );
392
        }
393
    },
394

395
    /**
396
     * Sets up behaviours and ui for generics.
397
     * Generics do not show up in structure board.
398
     *
399
     * @method _setGeneric
400
     * @private
401
     */
402
    _setGeneric: function() {
403
        var that = this;
24✔
404

405
        // adds double click to edit
406
        this.ui.container.off(Plugin.doubleClick).on(Plugin.doubleClick, function(e) {
24✔
407
            e.preventDefault();
×
408
            e.stopPropagation();
×
409
            that.editPlugin(Helpers.updateUrlWithPath(that.options.urls.edit_plugin), that.options.plugin_name, []);
×
410
        });
411

412
        // adds edit tooltip
413
        this.ui.container
24✔
414
            .off(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart)
415
            .on(Plugin.pointerOverAndOut + ' ' + Plugin.touchStart, function(e) {
416
                if (e.type !== 'touchstart') {
×
417
                    e.stopPropagation();
×
418
                }
419
                var name = that.options.plugin_name;
×
420
                var id = that.options.plugin_id;
×
421

422
                CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
423
            });
424
    },
425

426
    /**
427
     * Checks if paste is allowed into current plugin/placeholder based
428
     * on restrictions we have. Also determines which tooltip to show.
429
     *
430
     * WARNING: this relies on clipboard plugins always being instantiated
431
     * first, so they have data('cms') by the time this method is called.
432
     *
433
     * @method _checkIfPasteAllowed
434
     * @private
435
     * @returns {Boolean}
436
     */
437
    _checkIfPasteAllowed: function _checkIfPasteAllowed() {
438
        var pasteButton = this.ui.dropdown.find('[data-rel=paste]');
150✔
439
        var pasteItem = pasteButton.parent();
150✔
440

441
        if (!clipboardDraggable.length) {
150✔
442
            pasteItem.addClass('cms-submenu-item-disabled');
85✔
443
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
85✔
444
            pasteItem.find('.cms-submenu-item-paste-tooltip-empty').css('display', 'block');
85✔
445
            return false;
85✔
446
        }
447

448
        if (this.ui.draggable && this.ui.draggable.hasClass('cms-draggable-disabled')) {
65✔
449
            pasteItem.addClass('cms-submenu-item-disabled');
45✔
450
            pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
45✔
451
            pasteItem.find('.cms-submenu-item-paste-tooltip-disabled').css('display', 'block');
45✔
452
            return false;
45✔
453
        }
454

455
        var bounds = this.options.plugin_restriction;
20✔
456

457
        if (clipboardDraggable.data('cms')) {
20!
458
            var clipboardPluginData = clipboardDraggable.data('cms');
20✔
459
            var type = clipboardPluginData.plugin_type;
20✔
460
            var parent_bounds = $.grep(clipboardPluginData.plugin_parent_restriction, function(restriction) {
20✔
461
                // special case when PlaceholderPlugin has a parent restriction named "0"
462
                return restriction !== '0';
20✔
463
            });
464
            var currentPluginType = this.options.plugin_type;
20✔
465

466
            if (
20✔
467
                (bounds.length && $.inArray(type, bounds) === -1) ||
60!
468
                (parent_bounds.length && $.inArray(currentPluginType, parent_bounds) === -1)
469
            ) {
470
                pasteItem.addClass('cms-submenu-item-disabled');
15✔
471
                pasteItem.find('a').attr('tabindex', '-1').attr('aria-disabled', 'true');
15✔
472
                pasteItem.find('.cms-submenu-item-paste-tooltip-restricted').css('display', 'block');
15✔
473
                return false;
15✔
474
            }
475
        } else {
476
            return false;
×
477
        }
478

479
        pasteItem.find('a').removeAttr('tabindex').removeAttr('aria-disabled');
5✔
480
        pasteItem.removeClass('cms-submenu-item-disabled');
5✔
481

482
        return true;
5✔
483
    },
484

485
    /**
486
     * Calls api to create a plugin and then proceeds to edit it.
487
     *
488
     * @method addPlugin
489
     * @param {String} type type of the plugin, e.g "Bootstrap3ColumnCMSPlugin"
490
     * @param {String} name name of the plugin, e.g. "Column"
491
     * @param {String} parent id of a parent plugin
492
     */
493
    addPlugin: function(type, name, parent) {
494
        var params = {
3✔
495
            placeholder_id: this.options.placeholder_id,
496
            plugin_type: type,
497
            cms_path: path,
498
            plugin_language: CMS.config.request.language,
499
            plugin_position: this._getPluginAddPosition()
500
        };
501

502
        if (parent) {
3✔
503
            params.plugin_parent = parent;
1✔
504
        }
505
        var url = this.options.urls.add_plugin + '?' + $.param(params);
3✔
506
        var modal = new Modal({
3✔
507
            onClose: this.options.onClose || false,
5✔
508
            redirectOnClose: this.options.redirectOnClose || false
5✔
509
        });
510

511
        modal.open({
3✔
512
            url: url,
513
            title: name
514
        });
515

516
        this.modal = modal;
3✔
517

518
        Helpers.removeEventListener('modal-closed.add-plugin');
3✔
519
        Helpers.addEventListener('modal-closed.add-plugin', (e, { instance }) => {
3✔
520
            if (instance !== modal) {
1!
521
                return;
×
522
            }
523
            Plugin._removeAddPluginPlaceholder();
1✔
524
        });
525
    },
526

527
    _getPluginAddPosition: function() {
528
        if (this.options.type === 'placeholder') {
×
529
            return $(`.cms-dragarea-${this.options.placeholder_id} .cms-draggable`).length + 1;
×
530
        }
531

532
        // assume plugin now
533
        // would prefer to get the information from the tree, but the problem is that the flat data
534
        // isn't sorted by position
535
        const maybeChildren = this.ui.draggable.find('.cms-draggable');
×
536

537
        if (maybeChildren.length) {
×
538
            const lastChild = maybeChildren.last();
×
539

540
            const lastChildInstance = Plugin._getPluginById(this._getId(lastChild));
×
541

542
            return lastChildInstance.options.position + 1;
×
543
        }
544

545
        return this.options.position + 1;
×
546
    },
547

548
    /**
549
     * Opens the modal for editing a plugin.
550
     *
551
     * @method editPlugin
552
     * @param {String} url editing url
553
     * @param {String} name Name of the plugin, e.g. "Column"
554
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
555
     *     each item is `{ title: 'string': url: 'string' }`
556
     */
557
    editPlugin: function(url, name, breadcrumb) {
558
        // trigger modal window
559
        var modal = new Modal({
3✔
560
            onClose: this.options.onClose || false,
6✔
561
            redirectOnClose: this.options.redirectOnClose || false
6✔
562
        });
563

564
        this.modal = modal;
3✔
565

566
        Helpers.removeEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin');
3✔
567
        Helpers.addEventListener('modal-closed.edit-plugin modal-loaded.edit-plugin', (e, { instance }) => {
3✔
568
            if (instance === modal) {
1!
569
                // cannot be cached
570
                Plugin._removeAddPluginPlaceholder();
1✔
571
            }
572
        });
573
        modal.open({
3✔
574
            url: url,
575
            title: name,
576
            breadcrumbs: breadcrumb,
577
            width: 850
578
        });
579
    },
580

581
    /**
582
     * Used for copying _and_ pasting a plugin. If either of params
583
     * is present method assumes that it's "paste" and will make a call
584
     * to api to insert current plugin to specified `options.target_plugin_id`
585
     * or `options.target_placeholder_id`. Copying a plugin also first
586
     * clears the clipboard.
587
     *
588
     * @method copyPlugin
589
     * @param {Object} [opts=this.options]
590
     * @param {String} source_language
591
     * @returns {Boolean|void}
592
     */
593
    // eslint-disable-next-line complexity
594
    copyPlugin: function(opts, source_language) {
595
        // cancel request if already in progress
596
        if (CMS.API.locked) {
9✔
597
            return false;
1✔
598
        }
599
        CMS.API.locked = true;
8✔
600

601
        // set correct options (don't mutate them)
602
        var options = $.extend({}, opts || this.options);
8✔
603
        var sourceLanguage = source_language;
8✔
604
        let copyingFromLanguage = false;
8✔
605

606
        if (sourceLanguage) {
8✔
607
            copyingFromLanguage = true;
1✔
608
            options.target = options.placeholder_id;
1✔
609
            options.plugin_id = '';
1✔
610
            options.parent = '';
1✔
611
        } else {
612
            sourceLanguage = CMS.config.request.language;
7✔
613
        }
614

615
        var data = {
8✔
616
            source_placeholder_id: options.placeholder_id,
617
            source_plugin_id: options.plugin_id || '',
9✔
618
            source_language: sourceLanguage,
619
            target_plugin_id: options.parent || '',
16✔
620
            target_placeholder_id: options.target || CMS.config.clipboard.id,
15✔
621
            csrfmiddlewaretoken: CMS.config.csrf,
622
            target_language: CMS.config.request.language
623
        };
624
        var request = {
8✔
625
            type: 'POST',
626
            url: Helpers.updateUrlWithPath(options.urls.copy_plugin),
627
            data: data,
628
            success: function(response) {
629
                CMS.API.Messages.open({
2✔
630
                    message: CMS.config.lang.success
631
                });
632
                if (copyingFromLanguage) {
2!
633
                    CMS.API.StructureBoard.invalidateState('PASTE', $.extend({}, data, response));
×
634
                } else {
635
                    CMS.API.StructureBoard.invalidateState('COPY', response);
2✔
636
                }
637
                CMS.API.locked = false;
2✔
638
                hideLoader();
2✔
639
            },
640
            error: function(jqXHR) {
641
                CMS.API.locked = false;
3✔
642
                var msg = CMS.config.lang.error;
3✔
643

644
                // trigger error
645
                CMS.API.Messages.open({
3✔
646
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
4✔
647
                    error: true
648
                });
649
            }
650
        };
651

652
        $.ajax(request);
8✔
653
    },
654

655
    /**
656
     * Essentially clears clipboard and moves plugin to a clipboard
657
     * placholder through `movePlugin`.
658
     *
659
     * @method cutPlugin
660
     * @returns {Boolean|void}
661
     */
662
    cutPlugin: function() {
663
        // if cut is once triggered, prevent additional actions
664
        if (CMS.API.locked) {
9✔
665
            return false;
1✔
666
        }
667
        CMS.API.locked = true;
8✔
668

669
        var that = this;
8✔
670
        var data = {
8✔
671
            placeholder_id: CMS.config.clipboard.id,
672
            plugin_id: this.options.plugin_id,
673
            plugin_parent: '',
674
            target_language: CMS.config.request.language,
675
            csrfmiddlewaretoken: CMS.config.csrf
676
        };
677

678
        // move plugin
679
        $.ajax({
8✔
680
            type: 'POST',
681
            url: Helpers.updateUrlWithPath(that.options.urls.move_plugin),
682
            data: data,
683
            success: function(response) {
684
                CMS.API.locked = false;
4✔
685
                CMS.API.Messages.open({
4✔
686
                    message: CMS.config.lang.success
687
                });
688
                CMS.API.StructureBoard.invalidateState('CUT', $.extend({}, data, response));
4✔
689
                hideLoader();
4✔
690
            },
691
            error: function(jqXHR) {
692
                CMS.API.locked = false;
3✔
693
                var msg = CMS.config.lang.error;
3✔
694

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

705
    /**
706
     * Method is called when you click on the paste button on the plugin.
707
     * Uses existing solution of `copyPlugin(options)`
708
     *
709
     * @method pastePlugin
710
     */
711
    pastePlugin: function() {
712
        var id = this._getId(clipboardDraggable);
5✔
713
        var eventData = {
5✔
714
            id: id
715
        };
716

717
        const clipboardDraggableClone = clipboardDraggable.clone(true, true);
5✔
718

719
        clipboardDraggableClone.appendTo(this.ui.draggables);
5✔
720
        if (this.options.plugin_id) {
5✔
721
            StructureBoard.actualizePluginCollapseStatus(this.options.plugin_id);
4✔
722
        }
723
        this.ui.draggables.trigger('cms-structure-update', [eventData]);
5✔
724
        clipboardDraggableClone.trigger('cms-paste-plugin-update', [eventData]);
5✔
725
    },
726

727
    /**
728
     * Moves plugin by querying the API and then updates some UI parts
729
     * to reflect that the page has changed.
730
     *
731
     * @method movePlugin
732
     * @param {Object} [opts=this.options]
733
     * @param {String} [opts.placeholder_id]
734
     * @param {String} [opts.plugin_id]
735
     * @param {String} [opts.plugin_parent]
736
     * @param {Boolean} [opts.move_a_copy]
737
     * @returns {Boolean|void}
738
     */
739
    movePlugin: function(opts) {
740
        // cancel request if already in progress
741
        if (CMS.API.locked) {
12✔
742
            return false;
1✔
743
        }
744
        CMS.API.locked = true;
11✔
745

746
        // set correct options
747
        var options = opts || this.options;
11✔
748

749
        var dragitem = $(`.cms-draggable-${options.plugin_id}:last`);
11✔
750

751
        // SAVING POSITION
752
        var placeholder_id = this._getId(dragitem.parents('.cms-draggables').last().prevAll('.cms-dragbar').first());
11✔
753

754
        // cancel here if we have no placeholder id
755
        if (placeholder_id === false) {
11✔
756
            return false;
1✔
757
        }
758
        var pluginParentElement = dragitem.parent().closest('.cms-draggable');
10✔
759
        var plugin_parent = this._getId(pluginParentElement);
10✔
760

761
        // gather the data for ajax request
762
        var data = {
10✔
763
            plugin_id: options.plugin_id,
764
            plugin_parent: plugin_parent || '',
20✔
765
            target_language: CMS.config.request.language,
766
            csrfmiddlewaretoken: CMS.config.csrf,
767
            move_a_copy: options.move_a_copy
768
        };
769

770
        if (Number(placeholder_id) === Number(options.placeholder_id)) {
10!
771
            Plugin._updatePluginPositions(options.placeholder_id);
10✔
772
        } else {
773
            data.placeholder_id = placeholder_id;
×
774

775
            Plugin._updatePluginPositions(placeholder_id);
×
776
            Plugin._updatePluginPositions(options.placeholder_id);
×
777
        }
778

779
        var position = this.options.position;
10✔
780

781
        data.target_position = position;
10✔
782

783
        showLoader();
10✔
784

785
        $.ajax({
10✔
786
            type: 'POST',
787
            url: Helpers.updateUrlWithPath(options.urls.move_plugin),
788
            data: data,
789
            success: function(response) {
790
                CMS.API.StructureBoard.invalidateState(
4✔
791
                    data.move_a_copy ? 'PASTE' : 'MOVE',
4!
792
                    $.extend({}, data, { placeholder_id: placeholder_id }, response)
793
                );
794

795
                // enable actions again
796
                CMS.API.locked = false;
4✔
797
                hideLoader();
4✔
798
            },
799
            error: function(jqXHR) {
800
                CMS.API.locked = false;
4✔
801
                var msg = CMS.config.lang.error;
4✔
802

803
                // trigger error
804
                CMS.API.Messages.open({
4✔
805
                    message: msg + jqXHR.responseText || jqXHR.status + ' ' + jqXHR.statusText,
5✔
806
                    error: true
807
                });
808
                hideLoader();
4✔
809
            }
810
        });
811
    },
812

813
    /**
814
     * Changes the settings attributes on an initialised plugin.
815
     *
816
     * @method _setSettings
817
     * @param {Object} oldSettings current settings
818
     * @param {Object} newSettings new settings to be applied
819
     * @private
820
     */
821
    _setSettings: function _setSettings(oldSettings, newSettings) {
822
        var settings = $.extend(true, {}, oldSettings, newSettings);
×
823
        var plugin = $('.cms-plugin-' + settings.plugin_id);
×
824
        var draggable = $('.cms-draggable-' + settings.plugin_id);
×
825

826
        // set new setting on instance and plugin data
827
        this.options = settings;
×
828
        if (plugin.length) {
×
829
            var index = plugin.data('cms').findIndex(function(pluginData) {
×
830
                return pluginData.plugin_id === settings.plugin_id;
×
831
            });
832

833
            plugin.each(function() {
×
834
                $(this).data('cms')[index] = settings;
×
835
            });
836
        }
837
        if (draggable.length) {
×
838
            draggable.data('cms', settings);
×
839
        }
840
    },
841

842
    /**
843
     * Opens a modal to delete a plugin.
844
     *
845
     * @method deletePlugin
846
     * @param {String} url admin url for deleting a page
847
     * @param {String} name plugin name, e.g. "Column"
848
     * @param {Object[]} breadcrumb array of objects representing a breadcrumb,
849
     *     each item is `{ title: 'string': url: 'string' }`
850
     */
851
    deletePlugin: function(url, name, breadcrumb) {
852
        // trigger modal window
853
        var modal = new Modal({
2✔
854
            onClose: this.options.onClose || false,
4✔
855
            redirectOnClose: this.options.redirectOnClose || false
4✔
856
        });
857

858
        this.modal = modal;
2✔
859

860
        Helpers.removeEventListener('modal-loaded.delete-plugin');
2✔
861
        Helpers.addEventListener('modal-loaded.delete-plugin', (e, { instance }) => {
2✔
862
            if (instance === modal) {
5✔
863
                Plugin._removeAddPluginPlaceholder();
1✔
864
            }
865
        });
866
        modal.open({
2✔
867
            url: url,
868
            title: name,
869
            breadcrumbs: breadcrumb
870
        });
871
    },
872

873
    /**
874
     * Destroys the current plugin instance removing only the DOM listeners
875
     *
876
     * @method destroy
877
     * @param {Object}  options - destroy config options
878
     * @param {Boolean} options.mustCleanup - if true it will remove also the plugin UI components from the DOM
879
     * @returns {void}
880
     */
881
    destroy(options = {}) {
1✔
882
        const mustCleanup = options.mustCleanup || false;
2✔
883

884
        // close the plugin modal if it was open
885
        if (this.modal) {
2!
886
            this.modal.close();
×
887
            // unsubscribe to all the modal events
888
            this.modal.off();
×
889
        }
890

891
        if (mustCleanup) {
2✔
892
            this.cleanup();
1✔
893
        }
894

895
        // remove event bound to global elements like document or window
896
        $document.off(`.${this.uid}`);
2✔
897
        $window.off(`.${this.uid}`);
2✔
898
    },
899

900
    /**
901
     * Remove the plugin specific ui elements from the DOM
902
     *
903
     * @method cleanup
904
     * @returns {void}
905
     */
906
    cleanup() {
907
        // remove all the plugin UI DOM elements
908
        // notice that $.remove will remove also all the ui specific events
909
        // previously attached to them
910
        Object.keys(this.ui).forEach(el => this.ui[el].remove());
12✔
911
    },
912

913
    /**
914
     * Called after plugin is added through ajax.
915
     *
916
     * @method editPluginPostAjax
917
     * @param {Object} toolbar CMS.API.Toolbar instance (not used)
918
     * @param {Object} response response from server
919
     */
920
    editPluginPostAjax: function(toolbar, response) {
921
        this.editPlugin(Helpers.updateUrlWithPath(response.url), this.options.plugin_name, response.breadcrumb);
1✔
922
    },
923

924
    /**
925
     * _setSettingsMenu sets up event handlers for settings menu.
926
     *
927
     * @method _setSettingsMenu
928
     * @private
929
     * @param {jQuery} nav
930
     */
931
    _setSettingsMenu: function _setSettingsMenu(nav) {
932
        var that = this;
152✔
933

934
        this.ui.dropdown = nav.siblings('.cms-submenu-dropdown-settings');
152✔
935
        var dropdown = this.ui.dropdown;
152✔
936

937
        nav
152✔
938
            .off(Plugin.pointerUp)
939
            .on(Plugin.pointerUp, function(e) {
940
                e.preventDefault();
×
941
                e.stopPropagation();
×
942
                var trigger = $(this);
×
943

944
                if (trigger.hasClass('cms-btn-active')) {
×
945
                    Plugin._hideSettingsMenu(trigger);
×
946
                } else {
947
                    Plugin._hideSettingsMenu();
×
948
                    that._showSettingsMenu(trigger);
×
949
                }
950
            })
951
            .off(Plugin.touchStart)
952
            .on(Plugin.touchStart, function(e) {
953
                // required on some touch devices so
954
                // ui touch punch is not triggering mousemove
955
                // which in turn results in pep triggering pointercancel
956
                e.stopPropagation();
×
957
            });
958

959
        dropdown
152✔
960
            .off(Plugin.mouseEvents)
961
            .on(Plugin.mouseEvents, function(e) {
962
                e.stopPropagation();
×
963
            })
964
            .off(Plugin.touchStart)
965
            .on(Plugin.touchStart, function(e) {
966
                // required for scrolling on mobile
967
                e.stopPropagation();
×
968
            });
969

970
        that._setupActions(nav);
152✔
971
        // prevent propagation
972
        nav
152✔
973
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '))
974
            .on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
975
                e.stopPropagation();
×
976
            });
977

978
        nav
152✔
979
            .siblings('.cms-quicksearch, .cms-submenu-dropdown-settings')
980
            .off([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '))
981
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
982
                e.stopPropagation();
×
983
            });
984
    },
985

986
    /**
987
     * Simplistic implementation, only scrolls down, only works in structuremode
988
     * and highly depends on the styles of the structureboard to work correctly
989
     *
990
     * @method _scrollToElement
991
     * @private
992
     * @param {jQuery} el element to scroll to
993
     * @param {Object} [opts]
994
     * @param {Number} [opts.duration=200] time to scroll
995
     * @param {Number} [opts.offset=50] distance in px to the bottom of the screen
996
     */
997
    _scrollToElement: function _scrollToElement(el, opts) {
998
        var DEFAULT_DURATION = 200;
3✔
999
        var DEFAULT_OFFSET = 50;
3✔
1000
        var duration = opts && opts.duration !== undefined ? opts.duration : DEFAULT_DURATION;
3✔
1001
        var offset = opts && opts.offset !== undefined ? opts.offset : DEFAULT_OFFSET;
3✔
1002
        var scrollable = el.offsetParent();
3✔
1003
        var scrollHeight = $window.height();
3✔
1004
        var scrollTop = scrollable.scrollTop();
3✔
1005
        var elPosition = el.position().top;
3✔
1006
        var elHeight = el.height();
3✔
1007
        var isInViewport = elPosition + elHeight + offset <= scrollHeight;
3✔
1008

1009
        if (!isInViewport) {
3✔
1010
            scrollable.animate(
2✔
1011
                {
1012
                    scrollTop: elPosition + offset + elHeight + scrollTop - scrollHeight
1013
                },
1014
                duration
1015
            );
1016
        }
1017
    },
1018

1019
    /**
1020
     * Opens a modal with traversable plugins list, adds a placeholder to where
1021
     * the plugin will be added.
1022
     *
1023
     * @method _setAddPluginModal
1024
     * @private
1025
     * @param {jQuery} nav modal trigger element
1026
     * @returns {Boolean|void}
1027
     */
1028
    _setAddPluginModal: function _setAddPluginModal(nav) {
1029
        if (nav.hasClass('cms-btn-disabled')) {
152✔
1030
            return false;
87✔
1031
        }
1032
        var that = this;
65✔
1033
        var modal;
1034
        var isTouching;
1035
        var plugins;
1036

1037
        var initModal = once(function initModal() {
65✔
1038
            var placeholder = $(
×
1039
                '<div class="cms-add-plugin-placeholder">' + CMS.config.lang.addPluginPlaceholder + '</div>'
1040
            );
1041
            var dragItem = nav.closest('.cms-dragitem');
×
1042
            var isPlaceholder = !dragItem.length;
×
1043
            var childrenList;
1044

1045
            modal = new Modal({
×
1046
                minWidth: 400,
1047
                minHeight: 400
1048
            });
1049

1050
            if (isPlaceholder) {
×
1051
                childrenList = nav.closest('.cms-dragarea').find('> .cms-draggables');
×
1052
            } else {
1053
                childrenList = nav.closest('.cms-draggable').find('> .cms-draggables');
×
1054
            }
1055

1056
            Helpers.addEventListener('modal-loaded', (e, { instance }) => {
×
1057
                if (instance !== modal) {
×
1058
                    return;
×
1059
                }
1060

1061
                that._setupKeyboardTraversing();
×
1062
                if (childrenList.hasClass('cms-hidden') && !isPlaceholder) {
×
1063
                    that._toggleCollapsable(dragItem);
×
1064
                }
1065
                Plugin._removeAddPluginPlaceholder();
×
1066
                placeholder.appendTo(childrenList);
×
1067
                that._scrollToElement(placeholder);
×
1068
            });
1069

1070
            Helpers.addEventListener('modal-closed', (e, { instance }) => {
×
1071
                if (instance !== modal) {
×
1072
                    return;
×
1073
                }
1074
                Plugin._removeAddPluginPlaceholder();
×
1075
            });
1076

1077
            Helpers.addEventListener('modal-shown', (e, { instance }) => {
×
1078
                if (modal !== instance) {
×
1079
                    return;
×
1080
                }
1081
                var dropdown = $('.cms-modal-markup .cms-plugin-picker');
×
1082

1083
                if (!isTouching) {
×
1084
                    // only focus the field if using mouse
1085
                    // otherwise keyboard pops up
1086
                    dropdown.find('input').trigger('focus');
×
1087
                }
1088
                isTouching = false;
×
1089
            });
1090

1091
            plugins = nav.siblings('.cms-plugin-picker');
×
1092

1093
            that._setupQuickSearch(plugins);
×
1094
        });
1095

1096
        nav
65✔
1097
            .on(Plugin.touchStart, function(e) {
1098
                isTouching = true;
×
1099
                // required on some touch devices so
1100
                // ui touch punch is not triggering mousemove
1101
                // which in turn results in pep triggering pointercancel
1102
                e.stopPropagation();
×
1103
            })
1104
            .on(Plugin.pointerUp, function(e) {
1105
                e.preventDefault();
×
1106
                e.stopPropagation();
×
1107

1108
                Plugin._hideSettingsMenu();
×
1109

1110
                initModal();
×
1111

1112
                // since we don't know exact plugin parent (because dragndrop)
1113
                // we need to know the parent id by the time we open "add plugin" dialog
1114
                var pluginsCopy = that._updateWithMostUsedPlugins(
×
1115
                    plugins
1116
                        .clone(true, true)
1117
                        .data('parentId', that._getId(nav.closest('.cms-draggable')))
1118
                        .append(that._getPossibleChildClasses())
1119
                );
1120

1121
                modal.open({
×
1122
                    title: that.options.addPluginHelpTitle,
1123
                    html: pluginsCopy,
1124
                    width: 530,
1125
                    height: 400
1126
                });
1127
            });
1128

1129
        // prevent propagation
1130
        nav.on([Plugin.pointerUp, Plugin.pointerDown, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
65✔
1131
            e.stopPropagation();
×
1132
        });
1133

1134
        nav
65✔
1135
            .siblings('.cms-quicksearch, .cms-submenu-dropdown')
1136
            .on([Plugin.pointerUp, Plugin.click, Plugin.doubleClick].join(' '), function(e) {
1137
                e.stopPropagation();
×
1138
            });
1139
    },
1140

1141
    _updateWithMostUsedPlugins: function _updateWithMostUsedPlugins(plugins) {
1142
        const items = plugins.find('.cms-submenu-item');
×
1143
        // eslint-disable-next-line no-unused-vars
1144
        const mostUsedPlugins = toPairs(pluginUsageMap).sort(([x, a], [y, b]) => a - b).reverse();
×
1145
        const MAX_MOST_USED_PLUGINS = 5;
×
1146
        let count = 0;
×
1147

1148
        if (items.filter(':not(.cms-submenu-item-title)').length <= MAX_MOST_USED_PLUGINS) {
×
1149
            return plugins;
×
1150
        }
1151

1152
        let ref = plugins.find('.cms-quicksearch');
×
1153

1154
        mostUsedPlugins.forEach(([name]) => {
×
1155
            if (count === MAX_MOST_USED_PLUGINS) {
×
1156
                return;
×
1157
            }
1158
            const item = items.find(`[href=${name}]`);
×
1159

1160
            if (item.length) {
×
1161
                const clone = item.closest('.cms-submenu-item').clone(true, true);
×
1162

1163
                ref.after(clone);
×
1164
                ref = clone;
×
1165
                count += 1;
×
1166
            }
1167
        });
1168

1169
        if (count) {
×
1170
            plugins.find('.cms-quicksearch').after(
×
1171
                $(`<div class="cms-submenu-item cms-submenu-item-title" data-cms-most-used>
1172
                    <span>${CMS.config.lang.mostUsed}</span>
1173
                </div>`)
1174
            );
1175
        }
1176

1177
        return plugins;
×
1178
    },
1179

1180
    /**
1181
     * Returns a specific plugin namespaced event postfixing the plugin uid to it
1182
     * in order to properly manage it via jQuery $.on and $.off
1183
     *
1184
     * @method _getNamepacedEvent
1185
     * @private
1186
     * @param {String} base - plugin event type
1187
     * @param {String} additionalNS - additional namespace (like '.traverse' for example)
1188
     * @returns {String} a specific plugin event
1189
     *
1190
     * @example
1191
     *
1192
     * plugin._getNamepacedEvent(Plugin.click); // 'click.cms.plugin.42'
1193
     * plugin._getNamepacedEvent(Plugin.keyDown, '.traverse'); // 'keydown.cms.plugin.traverse.42'
1194
     */
1195
    _getNamepacedEvent(base, additionalNS = '') {
132✔
1196
        return `${base}${additionalNS ? '.'.concat(additionalNS) : ''}.${this.uid}`;
143✔
1197
    },
1198

1199
    /**
1200
     * Returns available plugin/placeholder child classes markup
1201
     * for "Add plugin" modal
1202
     *
1203
     * @method _getPossibleChildClasses
1204
     * @private
1205
     * @returns {jQuery} "add plugin" menu
1206
     */
1207
    _getPossibleChildClasses: function _getPossibleChildClasses() {
1208
        var that = this;
33✔
1209
        var childRestrictions = this.options.plugin_restriction;
33✔
1210
        // have to check the placeholder every time, since plugin could've been
1211
        // moved as part of another plugin
1212
        var placeholderId = that._getId(that.ui.submenu.closest('.cms-dragarea'));
33✔
1213
        var resultElements = $($('#cms-plugin-child-classes-' + placeholderId).html());
33✔
1214

1215
        if (childRestrictions && childRestrictions.length) {
33✔
1216
            resultElements = resultElements.filter(function() {
29✔
1217
                var item = $(this);
4,727✔
1218

1219
                return (
4,727✔
1220
                    item.hasClass('cms-submenu-item-title') ||
9,106✔
1221
                    childRestrictions.indexOf(item.find('a').attr('href')) !== -1
1222
                );
1223
            });
1224

1225
            resultElements = resultElements.filter(function(index) {
29✔
1226
                var item = $(this);
411✔
1227

1228
                return (
411✔
1229
                    !item.hasClass('cms-submenu-item-title') ||
1,182✔
1230
                    (item.hasClass('cms-submenu-item-title') &&
1231
                        (!resultElements.eq(index + 1).hasClass('cms-submenu-item-title') &&
1232
                            resultElements.eq(index + 1).length))
1233
                );
1234
            });
1235
        }
1236

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

1239
        return resultElements;
33✔
1240
    },
1241

1242
    /**
1243
     * Sets up event handlers for quicksearching in the plugin picker.
1244
     *
1245
     * @method _setupQuickSearch
1246
     * @private
1247
     * @param {jQuery} plugins plugins picker element
1248
     */
1249
    _setupQuickSearch: function _setupQuickSearch(plugins) {
1250
        var that = this;
×
1251
        var FILTER_DEBOUNCE_TIMER = 100;
×
1252
        var FILTER_PICK_DEBOUNCE_TIMER = 110;
×
1253

1254
        var handler = debounce(function() {
×
1255
            var input = $(this);
×
1256
            // have to always find the pluginsPicker in the handler
1257
            // because of how we move things into/out of the modal
1258
            var pluginsPicker = input.closest('.cms-plugin-picker');
×
1259

1260
            that._filterPluginsList(pluginsPicker, input);
×
1261
        }, FILTER_DEBOUNCE_TIMER);
1262

1263
        plugins.find('> .cms-quicksearch').find('input').on(Plugin.keyUp, handler).on(
×
1264
            Plugin.keyUp,
1265
            debounce(function(e) {
1266
                var input;
1267
                var pluginsPicker;
1268

1269
                if (e.keyCode === KEYS.ENTER) {
×
1270
                    input = $(this);
×
1271
                    pluginsPicker = input.closest('.cms-plugin-picker');
×
1272
                    pluginsPicker
×
1273
                        .find('.cms-submenu-item')
1274
                        .not('.cms-submenu-item-title')
1275
                        .filter(':visible')
1276
                        .first()
1277
                        .find('> a')
1278
                        .focus()
1279
                        .trigger('click');
1280
                }
1281
            }, FILTER_PICK_DEBOUNCE_TIMER)
1282
        );
1283
    },
1284

1285
    /**
1286
     * Sets up click handlers for various plugin/placeholder items.
1287
     * Items can be anywhere in the plugin dragitem, not only in dropdown.
1288
     *
1289
     * @method _setupActions
1290
     * @private
1291
     * @param {jQuery} nav dropdown trigger with the items
1292
     */
1293
    _setupActions: function _setupActions(nav) {
1294
        var items = '.cms-submenu-edit, .cms-submenu-item a';
162✔
1295
        var parent = nav.parent();
162✔
1296

1297
        parent.find('.cms-submenu-edit').off(Plugin.touchStart).on(Plugin.touchStart, function(e) {
162✔
1298
            // required on some touch devices so
1299
            // ui touch punch is not triggering mousemove
1300
            // which in turn results in pep triggering pointercancel
1301
            e.stopPropagation();
1✔
1302
        });
1303
        parent.find(items).off(Plugin.click).on(Plugin.click, nav, e => this._delegate(e));
162✔
1304
    },
1305

1306
    /**
1307
     * Handler for the "action" items
1308
     *
1309
     * @method _delegate
1310
     * @param {$.Event} e event
1311
     * @private
1312
     */
1313
    // eslint-disable-next-line complexity
1314
    _delegate: function _delegate(e) {
1315
        e.preventDefault();
13✔
1316
        e.stopPropagation();
13✔
1317

1318
        var nav;
1319
        var that = this;
13✔
1320

1321
        if (e.data && e.data.nav) {
13!
1322
            nav = e.data.nav;
×
1323
        }
1324

1325
        // show loader and make sure scroll doesn't jump
1326
        showLoader();
13✔
1327

1328
        var items = '.cms-submenu-edit, .cms-submenu-item a';
13✔
1329
        var el = $(e.target).closest(items);
13✔
1330

1331
        Plugin._hideSettingsMenu(nav);
13✔
1332

1333
        // set switch for subnav entries
1334
        switch (el.attr('data-rel')) {
13!
1335
            // eslint-disable-next-line no-case-declarations
1336
            case 'add':
1337
                const pluginType = el.attr('href').replace('#', '');
2✔
1338

1339
                Plugin._updateUsageCount(pluginType);
2✔
1340
                that.addPlugin(pluginType, el.text(), el.closest('.cms-plugin-picker').data('parentId'));
2✔
1341
                break;
2✔
1342
            case 'ajax_add':
1343
                CMS.API.Toolbar.openAjax({
1✔
1344
                    url: el.attr('href'),
1345
                    post: JSON.stringify(el.data('post')),
1346
                    text: el.data('text'),
1347
                    callback: $.proxy(that.editPluginPostAjax, that),
1348
                    onSuccess: el.data('on-success')
1349
                });
1350
                break;
1✔
1351
            case 'edit':
1352
                that.editPlugin(
1✔
1353
                    Helpers.updateUrlWithPath(that.options.urls.edit_plugin),
1354
                    that.options.plugin_name,
1355
                    that._getPluginBreadcrumbs()
1356
                );
1357
                break;
1✔
1358
            case 'copy-lang':
1359
                that.copyPlugin(that.options, el.attr('data-language'));
1✔
1360
                break;
1✔
1361
            case 'copy':
1362
                if (el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1363
                    hideLoader();
1✔
1364
                } else {
1365
                    that.copyPlugin();
1✔
1366
                }
1367
                break;
2✔
1368
            case 'cut':
1369
                that.cutPlugin();
1✔
1370
                break;
1✔
1371
            case 'paste':
1372
                hideLoader();
2✔
1373
                if (!el.parent().hasClass('cms-submenu-item-disabled')) {
2✔
1374
                    that.pastePlugin();
1✔
1375
                }
1376
                break;
2✔
1377
            case 'delete':
1378
                that.deletePlugin(
1✔
1379
                    Helpers.updateUrlWithPath(that.options.urls.delete_plugin),
1380
                    that.options.plugin_name,
1381
                    that._getPluginBreadcrumbs()
1382
                );
1383
                break;
1✔
1384
            case 'highlight':
1385
                hideLoader();
×
1386
                // eslint-disable-next-line no-magic-numbers
1387
                window.location.hash = `cms-plugin-${this.options.plugin_id}`;
×
1388
                Plugin._highlightPluginContent(this.options.plugin_id, { seeThrough: true });
×
1389
                e.stopImmediatePropagation();
×
1390
                break;
×
1391
            default:
1392
                hideLoader();
2✔
1393
                CMS.API.Toolbar._delegate(el);
2✔
1394
        }
1395
    },
1396

1397
    /**
1398
     * Sets up keyboard traversing of plugin picker.
1399
     *
1400
     * @method _setupKeyboardTraversing
1401
     * @private
1402
     */
1403
    _setupKeyboardTraversing: function _setupKeyboardTraversing() {
1404
        var dropdown = $('.cms-modal-markup .cms-plugin-picker');
3✔
1405
        const keyDownTraverseEvent = this._getNamepacedEvent(Plugin.keyDown, 'traverse');
3✔
1406

1407
        if (!dropdown.length) {
3✔
1408
            return;
1✔
1409
        }
1410
        // add key events
1411
        $document.off(keyDownTraverseEvent);
2✔
1412
        // istanbul ignore next: not really possible to reproduce focus state in unit tests
1413
        $document.on(keyDownTraverseEvent, function(e) {
1414
            var anchors = dropdown.find('.cms-submenu-item:visible a');
1415
            var index = anchors.index(anchors.filter(':focus'));
1416

1417
            // bind arrow down and tab keys
1418
            if (e.keyCode === KEYS.DOWN || (e.keyCode === KEYS.TAB && !e.shiftKey)) {
1419
                e.preventDefault();
1420
                if (index >= 0 && index < anchors.length - 1) {
1421
                    anchors.eq(index + 1).focus();
1422
                } else {
1423
                    anchors.eq(0).focus();
1424
                }
1425
            }
1426

1427
            // bind arrow up and shift+tab keys
1428
            if (e.keyCode === KEYS.UP || (e.keyCode === KEYS.TAB && e.shiftKey)) {
1429
                e.preventDefault();
1430
                if (anchors.is(':focus')) {
1431
                    anchors.eq(index - 1).focus();
1432
                } else {
1433
                    anchors.eq(anchors.length).focus();
1434
                }
1435
            }
1436
        });
1437
    },
1438

1439
    /**
1440
     * Opens the settings menu for a plugin.
1441
     *
1442
     * @method _showSettingsMenu
1443
     * @private
1444
     * @param {jQuery} nav trigger element
1445
     */
1446
    _showSettingsMenu: function(nav) {
1447
        this._checkIfPasteAllowed();
×
1448

1449
        var dropdown = this.ui.dropdown;
×
1450
        var parents = nav.parentsUntil('.cms-dragarea').last();
×
1451
        var MIN_SCREEN_MARGIN = 10;
×
1452

1453
        nav.addClass('cms-btn-active');
×
1454
        parents.addClass('cms-z-index-9999');
×
1455

1456
        // set visible states
1457
        dropdown.show();
×
1458

1459
        // calculate dropdown positioning
1460
        if (
×
1461
            $window.height() + $window.scrollTop() - nav.offset().top - dropdown.height() <= MIN_SCREEN_MARGIN &&
×
1462
            nav.offset().top - dropdown.height() >= 0
1463
        ) {
1464
            dropdown.removeClass('cms-submenu-dropdown-top').addClass('cms-submenu-dropdown-bottom');
×
1465
        } else {
1466
            dropdown.removeClass('cms-submenu-dropdown-bottom').addClass('cms-submenu-dropdown-top');
×
1467
        }
1468
    },
1469

1470
    /**
1471
     * Filters given plugins list by a query.
1472
     *
1473
     * @method _filterPluginsList
1474
     * @private
1475
     * @param {jQuery} list plugins picker element
1476
     * @param {jQuery} input input, which value to filter plugins with
1477
     * @returns {Boolean|void}
1478
     */
1479
    _filterPluginsList: function _filterPluginsList(list, input) {
1480
        var items = list.find('.cms-submenu-item');
5✔
1481
        var titles = list.find('.cms-submenu-item-title');
5✔
1482
        var query = input.val();
5✔
1483

1484
        // cancel if query is zero
1485
        if (query === '') {
5✔
1486
            items.add(titles).show();
1✔
1487
            return false;
1✔
1488
        }
1489

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

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

1494
        var itemsToFilter = items.toArray().map(function(el) {
4✔
1495
            var element = $(el);
72✔
1496

1497
            return {
72✔
1498
                value: element.text(),
1499
                element: element
1500
            };
1501
        });
1502

1503
        var filteredItems = fuzzyFilter(itemsToFilter, query, { key: 'value' });
4✔
1504

1505
        items.hide();
4✔
1506
        filteredItems.forEach(function(item) {
4✔
1507
            item.element.show();
3✔
1508
        });
1509

1510
        // check if a title is matching
1511
        titles.filter(':visible').each(function(index, item) {
4✔
1512
            titles.hide();
1✔
1513
            $(item).nextUntil('.cms-submenu-item-title').show();
1✔
1514
        });
1515

1516
        // always display title of a category
1517
        items.filter(':visible').each(function(index, titleItem) {
4✔
1518
            var item = $(titleItem);
16✔
1519

1520
            if (item.prev().hasClass('cms-submenu-item-title')) {
16✔
1521
                item.prev().show();
2✔
1522
            } else {
1523
                item.prevUntil('.cms-submenu-item-title').last().prev().show();
14✔
1524
            }
1525
        });
1526

1527
        mostRecentItems.hide();
4✔
1528
    },
1529

1530
    /**
1531
     * Toggles collapsable item.
1532
     *
1533
     * @method _toggleCollapsable
1534
     * @private
1535
     * @param {jQuery} el element to toggle
1536
     * @returns {Boolean|void}
1537
     */
1538
    _toggleCollapsable: function toggleCollapsable(el) {
1539
        var that = this;
×
1540
        var id = that._getId(el.parent());
×
1541
        var draggable = el.closest('.cms-draggable');
×
1542
        var items;
1543

1544
        var settings = CMS.settings;
×
1545

1546
        settings.states = settings.states || [];
×
1547

1548
        if (!draggable || !draggable.length) {
×
1549
            return;
×
1550
        }
1551

1552
        // collapsable function and save states
1553
        if (el.hasClass('cms-dragitem-expanded')) {
×
1554
            settings.states.splice($.inArray(id, settings.states), 1);
×
1555
            el
×
1556
                .removeClass('cms-dragitem-expanded')
1557
                .parent()
1558
                .find('> .cms-collapsable-container')
1559
                .addClass('cms-hidden');
1560

1561
            if ($document.data('expandmode')) {
×
1562
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1563
                if (!items.length) {
×
1564
                    return false;
×
1565
                }
1566
                items.each(function() {
×
1567
                    var item = $(this);
×
1568

1569
                    if (item.hasClass('cms-dragitem-expanded')) {
×
1570
                        that._toggleCollapsable(item);
×
1571
                    }
1572
                });
1573
            }
1574
        } else {
1575
            settings.states.push(id);
×
1576
            el
×
1577
                .addClass('cms-dragitem-expanded')
1578
                .parent()
1579
                .find('> .cms-collapsable-container')
1580
                .removeClass('cms-hidden');
1581

1582
            if ($document.data('expandmode')) {
×
1583
                items = draggable.find('.cms-draggable').find('.cms-dragitem-collapsable');
×
1584
                if (!items.length) {
×
1585
                    return false;
×
1586
                }
1587
                items.each(function() {
×
1588
                    var item = $(this);
×
1589

1590
                    if (!item.hasClass('cms-dragitem-expanded')) {
×
1591
                        that._toggleCollapsable(item);
×
1592
                    }
1593
                });
1594
            }
1595
        }
1596

1597
        this._updatePlaceholderCollapseState();
×
1598

1599
        // make sure structurboard gets updated after expanding
1600
        $document.trigger('resize.sideframe');
×
1601

1602
        // save settings
1603
        Helpers.setSettings(settings);
×
1604
    },
1605

1606
    _updatePlaceholderCollapseState() {
1607
        if (this.options.type !== 'plugin' || !this.options.placeholder_id) {
×
1608
            return;
×
1609
        }
1610

1611
        const pluginsOfCurrentPlaceholder = CMS._plugins
×
1612
            .filter(([, o]) => o.placeholder_id === this.options.placeholder_id && o.type === 'plugin')
×
1613
            .map(([, o]) => o.plugin_id);
×
1614

1615
        const openedPlugins = CMS.settings.states;
×
1616
        const closedPlugins = difference(pluginsOfCurrentPlaceholder, openedPlugins);
×
1617
        const areAllRemainingPluginsLeafs = every(closedPlugins, id => {
×
1618
            return !find(
×
1619
                CMS._plugins,
1620
                ([, o]) => o.placeholder_id === this.options.placeholder_id && o.plugin_parent === id
×
1621
            );
1622
        });
1623
        const el = $(`.cms-dragarea-${this.options.placeholder_id} .cms-dragbar-title`);
×
1624
        var settings = CMS.settings;
×
1625

1626
        if (areAllRemainingPluginsLeafs) {
×
1627
            // meaning that all plugins in current placeholder are expanded
1628
            el.addClass('cms-dragbar-title-expanded');
×
1629

1630
            settings.dragbars = settings.dragbars || [];
×
1631
            settings.dragbars.push(this.options.placeholder_id);
×
1632
        } else {
1633
            el.removeClass('cms-dragbar-title-expanded');
×
1634

1635
            settings.dragbars = settings.dragbars || [];
×
1636
            settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1637
        }
1638
    },
1639

1640
    /**
1641
     * Sets up collabspable event handlers.
1642
     *
1643
     * @method _collapsables
1644
     * @private
1645
     * @returns {Boolean|void}
1646
     */
1647
    _collapsables: function() {
1648
        // one time setup
1649
        var that = this;
152✔
1650

1651
        this.ui.draggable = $('.cms-draggable-' + this.options.plugin_id);
152✔
1652
        // cancel here if its not a draggable
1653
        if (!this.ui.draggable.length) {
152✔
1654
            return false;
38✔
1655
        }
1656

1657
        var dragitem = this.ui.draggable.find('> .cms-dragitem');
114✔
1658

1659
        // check which button should be shown for collapsemenu
1660
        var els = this.ui.draggable.find('.cms-dragitem-collapsable');
114✔
1661
        var open = els.filter('.cms-dragitem-expanded');
114✔
1662

1663
        if (els.length === open.length && els.length + open.length !== 0) {
114!
1664
            this.ui.draggable.find('.cms-dragbar-title').addClass('cms-dragbar-title-expanded');
×
1665
        }
1666

1667
        // attach events to draggable
1668
        // debounce here required because on some devices click is not triggered,
1669
        // so we consolidate latest click and touch event to run the collapse only once
1670
        dragitem.find('> .cms-dragitem-text').on(
114✔
1671
            Plugin.touchEnd + ' ' + Plugin.click,
1672
            debounce(function() {
1673
                if (!dragitem.hasClass('cms-dragitem-collapsable')) {
×
1674
                    return;
×
1675
                }
1676
                that._toggleCollapsable(dragitem);
×
1677
            }, 0)
1678
        );
1679
    },
1680

1681
    /**
1682
     * Expands all the collapsables in the given placeholder.
1683
     *
1684
     * @method _expandAll
1685
     * @private
1686
     * @param {jQuery} el trigger element that is a child of a placeholder
1687
     * @returns {Boolean|void}
1688
     */
1689
    _expandAll: function(el) {
1690
        var that = this;
×
1691
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1692

1693
        // cancel if there are no items
1694
        if (!items.length) {
×
1695
            return false;
×
1696
        }
1697
        items.each(function() {
×
1698
            var item = $(this);
×
1699

1700
            if (!item.hasClass('cms-dragitem-expanded')) {
×
1701
                that._toggleCollapsable(item);
×
1702
            }
1703
        });
1704

1705
        el.addClass('cms-dragbar-title-expanded');
×
1706

1707
        var settings = CMS.settings;
×
1708

1709
        settings.dragbars = settings.dragbars || [];
×
1710
        settings.dragbars.push(this.options.placeholder_id);
×
1711
        Helpers.setSettings(settings);
×
1712
    },
1713

1714
    /**
1715
     * Collapses all the collapsables in the given placeholder.
1716
     *
1717
     * @method _collapseAll
1718
     * @private
1719
     * @param {jQuery} el trigger element that is a child of a placeholder
1720
     */
1721
    _collapseAll: function(el) {
1722
        var that = this;
×
1723
        var items = el.closest('.cms-dragarea').find('.cms-dragitem-collapsable');
×
1724

1725
        items.each(function() {
×
1726
            var item = $(this);
×
1727

1728
            if (item.hasClass('cms-dragitem-expanded')) {
×
1729
                that._toggleCollapsable(item);
×
1730
            }
1731
        });
1732

1733
        el.removeClass('cms-dragbar-title-expanded');
×
1734

1735
        var settings = CMS.settings;
×
1736

1737
        settings.dragbars = settings.dragbars || [];
×
1738
        settings.dragbars.splice($.inArray(this.options.placeholder_id, settings.states), 1);
×
1739
        Helpers.setSettings(settings);
×
1740
    },
1741

1742
    /**
1743
     * Gets the id of the element, uses CMS.StructureBoard instance.
1744
     *
1745
     * @method _getId
1746
     * @private
1747
     * @param {jQuery} el element to get id from
1748
     * @returns {String}
1749
     */
1750
    _getId: function(el) {
1751
        return CMS.API.StructureBoard.getId(el);
36✔
1752
    },
1753

1754
    /**
1755
     * Gets the ids of the list of elements, uses CMS.StructureBoard instance.
1756
     *
1757
     * @method _getIds
1758
     * @private
1759
     * @param {jQuery} els elements to get id from
1760
     * @returns {String[]}
1761
     */
1762
    _getIds: function(els) {
1763
        return CMS.API.StructureBoard.getIds(els);
×
1764
    },
1765

1766
    /**
1767
     * Traverses the registry to find plugin parents
1768
     *
1769
     * @method _getPluginBreadcrumbs
1770
     * @returns {Object[]} array of breadcrumbs in `{ url, title }` format
1771
     * @private
1772
     */
1773
    _getPluginBreadcrumbs: function _getPluginBreadcrumbs() {
1774
        var breadcrumbs = [];
6✔
1775

1776
        breadcrumbs.unshift({
6✔
1777
            title: this.options.plugin_name,
1778
            url: this.options.urls.edit_plugin
1779
        });
1780

1781
        var findParentPlugin = function(id) {
6✔
1782
            return $.grep(CMS._plugins || [], function(pluginOptions) {
6✔
1783
                return pluginOptions[0] === 'cms-plugin-' + id;
10✔
1784
            })[0];
1785
        };
1786

1787
        var id = this.options.plugin_parent;
6✔
1788
        var data;
1789

1790
        while (id && id !== 'None') {
6✔
1791
            data = findParentPlugin(id);
6✔
1792

1793
            if (!data) {
6✔
1794
                break;
1✔
1795
            }
1796

1797
            breadcrumbs.unshift({
5✔
1798
                title: data[1].plugin_name,
1799
                url: data[1].urls.edit_plugin
1800
            });
1801
            id = data[1].plugin_parent;
5✔
1802
        }
1803

1804
        return breadcrumbs;
6✔
1805
    }
1806
});
1807

1808
Plugin.click = 'click.cms.plugin';
1✔
1809
Plugin.pointerUp = 'pointerup.cms.plugin';
1✔
1810
Plugin.pointerDown = 'pointerdown.cms.plugin';
1✔
1811
Plugin.pointerOverAndOut = 'pointerover.cms.plugin pointerout.cms.plugin';
1✔
1812
Plugin.doubleClick = 'dblclick.cms.plugin';
1✔
1813
Plugin.keyUp = 'keyup.cms.plugin';
1✔
1814
Plugin.keyDown = 'keydown.cms.plugin';
1✔
1815
Plugin.mouseEvents = 'mousedown.cms.plugin mousemove.cms.plugin mouseup.cms.plugin';
1✔
1816
Plugin.touchStart = 'touchstart.cms.plugin';
1✔
1817
Plugin.touchEnd = 'touchend.cms.plugin';
1✔
1818

1819
/**
1820
 * Updates plugin data in CMS._plugins / CMS._instances or creates new
1821
 * plugin instances if they didn't exist
1822
 *
1823
 * @method _updateRegistry
1824
 * @private
1825
 * @static
1826
 * @param {Object[]} plugins plugins data
1827
 */
1828
Plugin._updateRegistry = function _updateRegistry(plugins) {
1✔
1829
    plugins.forEach(pluginData => {
×
1830
        const pluginContainer = `cms-plugin-${pluginData.plugin_id}`;
×
1831
        const pluginIndex = findIndex(CMS._plugins, ([pluginStr]) => pluginStr === pluginContainer);
×
1832

1833
        if (pluginIndex === -1) {
×
1834
            CMS._plugins.push([pluginContainer, pluginData]);
×
1835
            CMS._instances.push(new Plugin(pluginContainer, pluginData));
×
1836
        } else {
1837
            Plugin.aliasPluginDuplicatesMap[pluginData.plugin_id] = false;
×
1838
            CMS._plugins[pluginIndex] = [pluginContainer, pluginData];
×
1839
            CMS._instances[pluginIndex] = new Plugin(pluginContainer, pluginData);
×
1840
        }
1841
    });
1842
};
1843

1844
/**
1845
 * Hides the opened settings menu. By default looks for any open ones.
1846
 *
1847
 * @method _hideSettingsMenu
1848
 * @static
1849
 * @private
1850
 * @param {jQuery} [navEl] element representing the subnav trigger
1851
 */
1852
Plugin._hideSettingsMenu = function(navEl) {
1✔
1853
    var nav = navEl || $('.cms-submenu-btn.cms-btn-active');
20✔
1854

1855
    if (!nav.length) {
20!
1856
        return;
20✔
1857
    }
1858
    nav.removeClass('cms-btn-active');
×
1859

1860
    // set correct active state
1861
    nav.closest('.cms-draggable').data('active', false);
×
1862
    $('.cms-z-index-9999').removeClass('cms-z-index-9999');
×
1863

1864
    nav.siblings('.cms-submenu-dropdown').hide();
×
1865
    nav.siblings('.cms-quicksearch').hide();
×
1866
    // reset search
1867
    nav.siblings('.cms-quicksearch').find('input').val('').trigger(Plugin.keyUp).blur();
×
1868

1869
    // reset relativity
1870
    $('.cms-dragbar').css('position', '');
×
1871
};
1872

1873
/**
1874
 * Initialises handlers that affect all plugins and don't make sense
1875
 * in context of each own plugin instance, e.g. listening for a click on a document
1876
 * to hide plugin settings menu should only be applied once, and not every time
1877
 * CMS.Plugin is instantiated.
1878
 *
1879
 * @method _initializeGlobalHandlers
1880
 * @static
1881
 * @private
1882
 */
1883
Plugin._initializeGlobalHandlers = function _initializeGlobalHandlers() {
1✔
1884
    var timer;
1885
    var clickCounter = 0;
6✔
1886

1887
    Plugin._updateClipboard();
6✔
1888

1889
    // Structureboard initialized too late
1890
    setTimeout(function() {
6✔
1891
        var pluginData = {};
6✔
1892
        var html = '';
6✔
1893

1894
        if (clipboardDraggable.length) {
6✔
1895
            pluginData = find(
5✔
1896
                CMS._plugins,
1897
                ([desc]) => desc === `cms-plugin-${CMS.API.StructureBoard.getId(clipboardDraggable)}`
10✔
1898
            )[1];
1899
            html = clipboardDraggable.parent().html();
5✔
1900
        }
1901
        if (CMS.API && CMS.API.Clipboard) {
6!
1902
            CMS.API.Clipboard.populate(html, pluginData);
6✔
1903
        }
1904
    }, 0);
1905

1906
    $document
6✔
1907
        .off(Plugin.pointerUp)
1908
        .off(Plugin.keyDown)
1909
        .off(Plugin.keyUp)
1910
        .off(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin')
1911
        .on(Plugin.pointerUp, function() {
1912
            // call it as a static method, because otherwise we trigger it the
1913
            // amount of times CMS.Plugin is instantiated,
1914
            // which does not make much sense.
1915
            Plugin._hideSettingsMenu();
×
1916
        })
1917
        .on(Plugin.keyDown, function(e) {
1918
            if (e.keyCode === KEYS.SHIFT) {
26!
1919
                $document.data('expandmode', true);
×
1920
                try {
×
1921
                    $('.cms-plugin:hover').last().trigger('mouseenter');
×
1922
                    $('.cms-dragitem:hover').last().trigger('mouseenter');
×
1923
                } catch (err) {}
1924
            }
1925
        })
1926
        .on(Plugin.keyUp, function(e) {
1927
            if (e.keyCode === KEYS.SHIFT) {
23!
1928
                $document.data('expandmode', false);
×
1929
                try {
×
1930
                    $(':hover').trigger('mouseleave');
×
1931
                } catch (err) {}
1932
            }
1933
        })
1934
        .on(Plugin.click, '.cms-plugin a, a:has(.cms-plugin), a.cms-plugin', function(e) {
1935
            var DOUBLECLICK_DELAY = 300;
×
1936

1937
            // prevents single click from messing up the edit call
1938
            // don't go to the link if there is custom js attached to it
1939
            // or if it's clicked along with shift, ctrl, cmd
1940
            if (e.shiftKey || e.ctrlKey || e.metaKey || e.isDefaultPrevented()) {
×
1941
                return;
×
1942
            }
1943
            e.preventDefault();
×
1944
            if (++clickCounter === 1) {
×
1945
                timer = setTimeout(function() {
×
1946
                    var anchor = $(e.target).closest('a');
×
1947

1948
                    clickCounter = 0;
×
1949
                    window.open(anchor.attr('href'), anchor.attr('target') || '_self');
×
1950
                }, DOUBLECLICK_DELAY);
1951
            } else {
1952
                clearTimeout(timer);
×
1953
                clickCounter = 0;
×
1954
            }
1955
        });
1956

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

1966
        e.stopPropagation();
×
1967
        const pluginContainer = $(e.target).closest('.cms-plugin');
×
1968
        const allOptions = pluginContainer.data('cms');
×
1969

1970
        if (!allOptions || !allOptions.length) {
×
1971
            return;
×
1972
        }
1973

1974
        const options = allOptions[0];
×
1975

1976
        if (e.type === 'touchstart') {
×
1977
            CMS.API.Tooltip._forceTouchOnce();
×
1978
        }
1979
        var name = options.plugin_name;
×
1980
        var id = options.plugin_id;
×
1981
        var type = options.type;
×
1982

1983
        if (type === 'generic') {
×
1984
            return;
×
1985
        }
1986
        var placeholderId = CMS.API.StructureBoard.getId($(`.cms-draggable-${id}`).closest('.cms-dragarea'));
×
1987
        var placeholder = $('.cms-placeholder-' + placeholderId);
×
1988

1989
        if (placeholder.length && placeholder.data('cms')) {
×
1990
            name = placeholder.data('cms').name + ': ' + name;
×
1991
        }
1992

1993
        CMS.API.Tooltip.displayToggle(e.type === 'pointerover' || e.type === 'touchstart', e, name, id);
×
1994
    });
1995

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

1999
        if (placeholder.hasClass('cms-dragarea-static-expanded') && e.isDefaultPrevented()) {
×
2000
            return;
×
2001
        }
2002

2003
        placeholder.toggleClass('cms-dragarea-static-expanded');
×
2004
    });
2005

2006
    $window.on('blur.cms', () => {
6✔
2007
        $document.data('expandmode', false);
6✔
2008
    });
2009
};
2010

2011
/**
2012
 * @method _isContainingMultiplePlugins
2013
 * @param {jQuery} node to check
2014
 * @static
2015
 * @private
2016
 * @returns {Boolean}
2017
 */
2018
Plugin._isContainingMultiplePlugins = function _isContainingMultiplePlugins(node) {
1✔
2019
    var currentData = node.data('cms');
129✔
2020

2021
    // istanbul ignore if
2022
    if (!currentData) {
129✔
2023
        throw new Error('Provided node is not a cms plugin.');
2024
    }
2025

2026
    var pluginIds = currentData.map(function(pluginData) {
129✔
2027
        return pluginData.plugin_id;
130✔
2028
    });
2029

2030
    if (pluginIds.length > 1) {
129✔
2031
        // another plugin already lives on the same node
2032
        // this only works because the plugins are rendered from
2033
        // the bottom to the top (leaf to root)
2034
        // meaning the deepest plugin is always first
2035
        return true;
1✔
2036
    }
2037

2038
    return false;
128✔
2039
};
2040

2041
/**
2042
 * Shows and immediately fades out a success notification (when
2043
 * plugin was successfully moved.
2044
 *
2045
 * @method _highlightPluginStructure
2046
 * @private
2047
 * @static
2048
 * @param {jQuery} el draggable element
2049
 */
2050
// eslint-disable-next-line no-magic-numbers
2051
Plugin._highlightPluginStructure = function _highlightPluginStructure(
1✔
2052
    el,
2053
    // eslint-disable-next-line no-magic-numbers
2054
    { successTimeout = 200, delay = 1500, seeThrough = false }
×
2055
) {
2056
    const tpl = $(`
×
2057
        <div class="cms-dragitem-success ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}">
×
2058
        </div>
2059
    `);
2060

2061
    el.addClass('cms-draggable-success').append(tpl);
×
2062
    // start animation
2063
    if (successTimeout) {
×
2064
        setTimeout(() => {
×
2065
            tpl.fadeOut(successTimeout, function() {
×
2066
                $(this).remove();
×
2067
                el.removeClass('cms-draggable-success');
×
2068
            });
2069
        }, delay);
2070
    }
2071
    // make sure structurboard gets updated after success
2072
    $(Helpers._getWindow()).trigger('resize.sideframe');
×
2073
};
2074

2075
/**
2076
 * Highlights plugin in content mode
2077
 *
2078
 * @method _highlightPluginContent
2079
 * @private
2080
 * @static
2081
 * @param {String|Number} pluginId
2082
 */
2083
Plugin._highlightPluginContent = function _highlightPluginContent(
1✔
2084
    pluginId,
2085
    // eslint-disable-next-line no-magic-numbers
2086
    { successTimeout = 200, seeThrough = false, delay = 1500, prominent = false } = {}
5✔
2087
) {
2088
    var coordinates = {};
1✔
2089
    var positions = [];
1✔
2090
    var OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO = 0.2;
1✔
2091

2092
    $('.cms-plugin-' + pluginId).each(function() {
1✔
2093
        var el = $(this);
1✔
2094
        var offset = el.offset();
1✔
2095
        var ml = parseInt(el.css('margin-left'), 10);
1✔
2096
        var mr = parseInt(el.css('margin-right'), 10);
1✔
2097
        var mt = parseInt(el.css('margin-top'), 10);
1✔
2098
        var mb = parseInt(el.css('margin-bottom'), 10);
1✔
2099
        var width = el.outerWidth();
1✔
2100
        var height = el.outerHeight();
1✔
2101

2102
        if (width === 0 && height === 0) {
1!
2103
            return;
×
2104
        }
2105

2106
        if (isNaN(ml)) {
1!
2107
            ml = 0;
×
2108
        }
2109
        if (isNaN(mr)) {
1!
2110
            mr = 0;
×
2111
        }
2112
        if (isNaN(mt)) {
1!
2113
            mt = 0;
×
2114
        }
2115
        if (isNaN(mb)) {
1!
2116
            mb = 0;
×
2117
        }
2118

2119
        positions.push({
1✔
2120
            x1: offset.left - ml,
2121
            x2: offset.left + width + mr,
2122
            y1: offset.top - mt,
2123
            y2: offset.top + height + mb
2124
        });
2125
    });
2126

2127
    if (positions.length === 0) {
1!
2128
        return;
×
2129
    }
2130

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

2136
    coordinates.left = Math.min(...positions.map(pos => pos.x1));
1✔
2137
    coordinates.top = Math.min(...positions.map(pos => pos.y1)) - htmlMargin;
1✔
2138
    coordinates.width = Math.max(...positions.map(pos => pos.x2)) - coordinates.left;
1✔
2139
    coordinates.height = Math.max(...positions.map(pos => pos.y2)) - coordinates.top - htmlMargin;
1✔
2140

2141
    $window.scrollTop(coordinates.top - $window.height() * OVERLAY_POSITION_TO_WINDOW_HEIGHT_RATIO);
1✔
2142

2143
    $(
1✔
2144
        `
2145
        <div class="
2146
            cms-plugin-overlay
2147
            cms-dragitem-success
2148
            cms-plugin-overlay-${pluginId}
2149
            ${seeThrough ? 'cms-plugin-overlay-see-through' : ''}
1!
2150
            ${prominent ? 'cms-plugin-overlay-prominent' : ''}
1!
2151
        "
2152
            data-success-timeout="${successTimeout}"
2153
        >
2154
        </div>
2155
    `
2156
    )
2157
        .css(coordinates)
2158
        .css({
2159
            zIndex: 9999
2160
        })
2161
        .appendTo($('body'));
2162

2163
    if (successTimeout) {
1!
2164
        setTimeout(() => {
1✔
2165
            $(`.cms-plugin-overlay-${pluginId}`).fadeOut(successTimeout, function() {
1✔
2166
                $(this).remove();
1✔
2167
            });
2168
        }, delay);
2169
    }
2170
};
2171

2172
Plugin._clickToHighlightHandler = function _clickToHighlightHandler(e) {
1✔
2173
    if (CMS.settings.mode !== 'structure') {
×
2174
        return;
×
2175
    }
2176
    e.preventDefault();
×
2177
    e.stopPropagation();
×
2178
    // FIXME refactor into an object
2179
    CMS.API.StructureBoard._showAndHighlightPlugin(200, true); // eslint-disable-line no-magic-numbers
×
2180
};
2181

2182
Plugin._removeHighlightPluginContent = function(pluginId) {
1✔
2183
    $(`.cms-plugin-overlay-${pluginId}[data-success-timeout=0]`).remove();
×
2184
};
2185

2186
Plugin.aliasPluginDuplicatesMap = {};
1✔
2187
Plugin.staticPlaceholderDuplicatesMap = {};
1✔
2188

2189
// istanbul ignore next
2190
Plugin._initializeTree = function _initializeTree() {
2191
    CMS._plugins = uniqWith(CMS._plugins, ([x], [y]) => x === y);
2192
    CMS._instances = CMS._plugins.map(function(args) {
2193
        return new CMS.Plugin(args[0], args[1]);
2194
    });
2195

2196
    // return the cms plugin instances just created
2197
    return CMS._instances;
2198
};
2199

2200
Plugin._updateClipboard = function _updateClipboard() {
1✔
2201
    clipboardDraggable = $('.cms-draggable-from-clipboard:first');
7✔
2202
};
2203

2204
Plugin._updateUsageCount = function _updateUsageCount(pluginType) {
1✔
2205
    var currentValue = pluginUsageMap[pluginType] || 0;
2✔
2206

2207
    pluginUsageMap[pluginType] = currentValue + 1;
2✔
2208

2209
    if (Helpers._isStorageSupported) {
2!
2210
        localStorage.setItem('cms-plugin-usage', JSON.stringify(pluginUsageMap));
×
2211
    }
2212
};
2213

2214
Plugin._removeAddPluginPlaceholder = function removeAddPluginPlaceholder() {
1✔
2215
    // this can't be cached since they are created and destroyed all over the place
2216
    $('.cms-add-plugin-placeholder').remove();
10✔
2217
};
2218

2219
Plugin._refreshPlugins = function refreshPlugins() {
1✔
2220
    Plugin.aliasPluginDuplicatesMap = {};
4✔
2221
    Plugin.staticPlaceholderDuplicatesMap = {};
4✔
2222
    CMS._plugins = uniqWith(CMS._plugins, isEqual);
4✔
2223

2224
    CMS._instances.forEach(instance => {
4✔
2225
        if (instance.options.type === 'placeholder') {
5✔
2226
            instance._setupUI(`cms-placeholder-${instance.options.placeholder_id}`);
2✔
2227
            instance._ensureData();
2✔
2228
            instance.ui.container.data('cms', instance.options);
2✔
2229
            instance._setPlaceholder();
2✔
2230
        }
2231
    });
2232

2233
    CMS._instances.forEach(instance => {
4✔
2234
        if (instance.options.type === 'plugin') {
5✔
2235
            instance._setupUI(`cms-plugin-${instance.options.plugin_id}`);
2✔
2236
            instance._ensureData();
2✔
2237
            instance.ui.container.data('cms').push(instance.options);
2✔
2238
            instance._setPluginContentEvents();
2✔
2239
        }
2240
    });
2241

2242
    CMS._plugins.forEach(([type, opts]) => {
4✔
2243
        if (opts.type !== 'placeholder' && opts.type !== 'plugin') {
16✔
2244
            const instance = find(
8✔
2245
                CMS._instances,
2246
                i => i.options.type === opts.type && Number(i.options.plugin_id) === Number(opts.plugin_id)
13✔
2247
            );
2248

2249
            if (instance) {
8✔
2250
                // update
2251
                instance._setupUI(type);
1✔
2252
                instance._ensureData();
1✔
2253
                instance.ui.container.data('cms').push(instance.options);
1✔
2254
                instance._setGeneric();
1✔
2255
            } else {
2256
                // create
2257
                CMS._instances.push(new Plugin(type, opts));
7✔
2258
            }
2259
        }
2260
    });
2261
};
2262

2263
Plugin._getPluginById = function(id) {
1✔
2264
    return find(CMS._instances, ({ options }) => options.type === 'plugin' && Number(options.plugin_id) === Number(id));
20!
2265
};
2266

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

2272
    plugins.forEach((element, index) => {
10✔
2273
        const pluginId = CMS.API.StructureBoard.getId($(element));
20✔
2274
        const instance = Plugin._getPluginById(pluginId);
20✔
2275

2276
        if (!instance) {
20!
2277
            return;
20✔
2278
        }
2279

2280
        instance.options.position = index + 1;
×
2281
    });
2282
};
2283

2284
Plugin._recalculatePluginPositions = function(action, data) {
1✔
2285
    if (action === 'MOVE') {
×
2286
        // le sigh - recalculate all placeholders cause we don't know from where the
2287
        // plugin was moved from
2288
        filter(CMS._instances, ({ options }) => options.type === 'placeholder')
×
2289
            .map(({ options }) => options.placeholder_id)
×
2290
            .forEach(placeholder_id => Plugin._updatePluginPositions(placeholder_id));
×
2291
    } else if (data.placeholder_id) {
×
2292
        Plugin._updatePluginPositions(data.placeholder_id);
×
2293
    }
2294
};
2295

2296
// shorthand for jQuery(document).ready();
2297
$(Plugin._initializeGlobalHandlers);
1✔
2298

2299
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

© 2025 Coveralls, Inc