]> scripts.mit.edu Git - autoinstalls/wordpress.git/blob - wp-admin/js/customize-widgets.js
WordPress 4.1.3
[autoinstalls/wordpress.git] / wp-admin / js / customize-widgets.js
1 /* global _wpCustomizeWidgetsSettings */
2 (function( wp, $ ){
3
4         if ( ! wp || ! wp.customize ) { return; }
5
6         // Set up our namespace...
7         var api = wp.customize,
8                 l10n;
9
10         api.Widgets = api.Widgets || {};
11
12         // Link settings
13         api.Widgets.data = _wpCustomizeWidgetsSettings || {};
14         l10n = api.Widgets.data.l10n;
15         delete api.Widgets.data.l10n;
16
17         /**
18          * wp.customize.Widgets.WidgetModel
19          *
20          * A single widget model.
21          *
22          * @constructor
23          * @augments Backbone.Model
24          */
25         api.Widgets.WidgetModel = Backbone.Model.extend({
26                 id: null,
27                 temp_id: null,
28                 classname: null,
29                 control_tpl: null,
30                 description: null,
31                 is_disabled: null,
32                 is_multi: null,
33                 multi_number: null,
34                 name: null,
35                 id_base: null,
36                 transport: 'refresh',
37                 params: [],
38                 width: null,
39                 height: null,
40                 search_matched: true
41         });
42
43         /**
44          * wp.customize.Widgets.WidgetCollection
45          *
46          * Collection for widget models.
47          *
48          * @constructor
49          * @augments Backbone.Model
50          */
51         api.Widgets.WidgetCollection = Backbone.Collection.extend({
52                 model: api.Widgets.WidgetModel,
53
54                 // Controls searching on the current widget collection
55                 // and triggers an update event
56                 doSearch: function( value ) {
57
58                         // Don't do anything if we've already done this search
59                         // Useful because the search handler fires multiple times per keystroke
60                         if ( this.terms === value ) {
61                                 return;
62                         }
63
64                         // Updates terms with the value passed
65                         this.terms = value;
66
67                         // If we have terms, run a search...
68                         if ( this.terms.length > 0 ) {
69                                 this.search( this.terms );
70                         }
71
72                         // If search is blank, show all themes
73                         // Useful for resetting the views when you clean the input
74                         if ( this.terms === '' ) {
75                                 this.each( function ( widget ) {
76                                         widget.set( 'search_matched', true );
77                                 } );
78                         }
79                 },
80
81                 // Performs a search within the collection
82                 // @uses RegExp
83                 search: function( term ) {
84                         var match, haystack;
85
86                         // Escape the term string for RegExp meta characters
87                         term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
88
89                         // Consider spaces as word delimiters and match the whole string
90                         // so matching terms can be combined
91                         term = term.replace( / /g, ')(?=.*' );
92                         match = new RegExp( '^(?=.*' + term + ').+', 'i' );
93
94                         this.each( function ( data ) {
95                                 haystack = [ data.get( 'name' ), data.get( 'id' ), data.get( 'description' ) ].join( ' ' );
96                                 data.set( 'search_matched', match.test( haystack ) );
97                         } );
98                 }
99         });
100         api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
101
102         /**
103          * wp.customize.Widgets.SidebarModel
104          *
105          * A single sidebar model.
106          *
107          * @constructor
108          * @augments Backbone.Model
109          */
110         api.Widgets.SidebarModel = Backbone.Model.extend({
111                 after_title: null,
112                 after_widget: null,
113                 before_title: null,
114                 before_widget: null,
115                 'class': null,
116                 description: null,
117                 id: null,
118                 name: null,
119                 is_rendered: false
120         });
121
122         /**
123          * wp.customize.Widgets.SidebarCollection
124          *
125          * Collection for sidebar models.
126          *
127          * @constructor
128          * @augments Backbone.Collection
129          */
130         api.Widgets.SidebarCollection = Backbone.Collection.extend({
131                 model: api.Widgets.SidebarModel
132         });
133         api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
134
135         /**
136          * wp.customize.Widgets.AvailableWidgetsPanelView
137          *
138          * View class for the available widgets panel.
139          *
140          * @constructor
141          * @augments wp.Backbone.View
142          * @augments Backbone.View
143          */
144         api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend({
145
146                 el: '#available-widgets',
147
148                 events: {
149                         'input #widgets-search': 'search',
150                         'keyup #widgets-search': 'search',
151                         'change #widgets-search': 'search',
152                         'search #widgets-search': 'search',
153                         'focus .widget-tpl' : 'focus',
154                         'click .widget-tpl' : '_submit',
155                         'keypress .widget-tpl' : '_submit',
156                         'keydown' : 'keyboardAccessible'
157                 },
158
159                 // Cache current selected widget
160                 selected: null,
161
162                 // Cache sidebar control which has opened panel
163                 currentSidebarControl: null,
164                 $search: null,
165
166                 initialize: function() {
167                         var self = this;
168
169                         this.$search = $( '#widgets-search' );
170
171                         _.bindAll( this, 'close' );
172
173                         this.listenTo( this.collection, 'change', this.updateList );
174
175                         this.updateList();
176
177                         // If the available widgets panel is open and the customize controls are
178                         // interacted with (i.e. available widgets panel is blurred) then close the
179                         // available widgets panel.
180                         $( '#customize-controls' ).on( 'click keydown', function( e ) {
181                                 var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
182                                 if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
183                                         self.close();
184                                 }
185                         } );
186
187                         // Close the panel if the URL in the preview changes
188                         api.previewer.bind( 'url', this.close );
189                 },
190
191                 // Performs a search and handles selected widget
192                 search: function( event ) {
193                         var firstVisible;
194
195                         this.collection.doSearch( event.target.value );
196
197                         // Remove a widget from being selected if it is no longer visible
198                         if ( this.selected && ! this.selected.is( ':visible' ) ) {
199                                 this.selected.removeClass( 'selected' );
200                                 this.selected = null;
201                         }
202
203                         // If a widget was selected but the filter value has been cleared out, clear selection
204                         if ( this.selected && ! event.target.value ) {
205                                 this.selected.removeClass( 'selected' );
206                                 this.selected = null;
207                         }
208
209                         // If a filter has been entered and a widget hasn't been selected, select the first one shown
210                         if ( ! this.selected && event.target.value ) {
211                                 firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
212                                 if ( firstVisible.length ) {
213                                         this.select( firstVisible );
214                                 }
215                         }
216                 },
217
218                 // Changes visibility of available widgets
219                 updateList: function() {
220                         this.collection.each( function( widget ) {
221                                 var widgetTpl = $( '#widget-tpl-' + widget.id );
222                                 widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
223                                 if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
224                                         this.selected = null;
225                                 }
226                         } );
227                 },
228
229                 // Highlights a widget
230                 select: function( widgetTpl ) {
231                         this.selected = $( widgetTpl );
232                         this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
233                         this.selected.addClass( 'selected' );
234                 },
235
236                 // Highlights a widget on focus
237                 focus: function( event ) {
238                         this.select( $( event.currentTarget ) );
239                 },
240
241                 // Submit handler for keypress and click on widget
242                 _submit: function( event ) {
243                         // Only proceed with keypress if it is Enter or Spacebar
244                         if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
245                                 return;
246                         }
247
248                         this.submit( $( event.currentTarget ) );
249                 },
250
251                 // Adds a selected widget to the sidebar
252                 submit: function( widgetTpl ) {
253                         var widgetId, widget;
254
255                         if ( ! widgetTpl ) {
256                                 widgetTpl = this.selected;
257                         }
258
259                         if ( ! widgetTpl || ! this.currentSidebarControl ) {
260                                 return;
261                         }
262
263                         this.select( widgetTpl );
264
265                         widgetId = $( this.selected ).data( 'widget-id' );
266                         widget = this.collection.findWhere( { id: widgetId } );
267                         if ( ! widget ) {
268                                 return;
269                         }
270
271                         this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
272
273                         this.close();
274                 },
275
276                 // Opens the panel
277                 open: function( sidebarControl ) {
278                         this.currentSidebarControl = sidebarControl;
279
280                         // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens
281                         _( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
282                                 if ( control.params.is_wide ) {
283                                         control.collapseForm();
284                                 }
285                         } );
286
287                         $( 'body' ).addClass( 'adding-widget' );
288
289                         this.$el.find( '.selected' ).removeClass( 'selected' );
290
291                         // Reset search
292                         this.collection.doSearch( '' );
293
294                         this.$search.focus();
295                 },
296
297                 // Closes the panel
298                 close: function( options ) {
299                         options = options || {};
300
301                         if ( options.returnFocus && this.currentSidebarControl ) {
302                                 this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
303                         }
304
305                         this.currentSidebarControl = null;
306                         this.selected = null;
307
308                         $( 'body' ).removeClass( 'adding-widget' );
309
310                         this.$search.val( '' );
311                 },
312
313                 // Add keyboard accessiblity to the panel
314                 keyboardAccessible: function( event ) {
315                         var isEnter = ( event.which === 13 ),
316                                 isEsc = ( event.which === 27 ),
317                                 isDown = ( event.which === 40 ),
318                                 isUp = ( event.which === 38 ),
319                                 isTab = ( event.which === 9 ),
320                                 isShift = ( event.shiftKey ),
321                                 selected = null,
322                                 firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
323                                 lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
324                                 isSearchFocused = $( event.target ).is( this.$search ),
325                                 isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
326
327                         if ( isDown || isUp ) {
328                                 if ( isDown ) {
329                                         if ( isSearchFocused ) {
330                                                 selected = firstVisible;
331                                         } else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
332                                                 selected = this.selected.nextAll( '.widget-tpl:visible:first' );
333                                         }
334                                 } else if ( isUp ) {
335                                         if ( isSearchFocused ) {
336                                                 selected = lastVisible;
337                                         } else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
338                                                 selected = this.selected.prevAll( '.widget-tpl:visible:first' );
339                                         }
340                                 }
341
342                                 this.select( selected );
343
344                                 if ( selected ) {
345                                         selected.focus();
346                                 } else {
347                                         this.$search.focus();
348                                 }
349
350                                 return;
351                         }
352
353                         // If enter pressed but nothing entered, don't do anything
354                         if ( isEnter && ! this.$search.val() ) {
355                                 return;
356                         }
357
358                         if ( isEnter ) {
359                                 this.submit();
360                         } else if ( isEsc ) {
361                                 this.close( { returnFocus: true } );
362                         }
363
364                         if ( isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
365                                 this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
366                                 event.preventDefault();
367                         }
368                 }
369         });
370
371         /**
372          * Handlers for the widget-synced event, organized by widget ID base.
373          * Other widgets may provide their own update handlers by adding
374          * listeners for the widget-synced event.
375          */
376         api.Widgets.formSyncHandlers = {
377
378                 /**
379                  * @param {jQuery.Event} e
380                  * @param {jQuery} widget
381                  * @param {String} newForm
382                  */
383                 rss: function( e, widget, newForm ) {
384                         var oldWidgetError = widget.find( '.widget-error:first' ),
385                                 newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
386
387                         if ( oldWidgetError.length && newWidgetError.length ) {
388                                 oldWidgetError.replaceWith( newWidgetError );
389                         } else if ( oldWidgetError.length ) {
390                                 oldWidgetError.remove();
391                         } else if ( newWidgetError.length ) {
392                                 widget.find( '.widget-content:first' ).prepend( newWidgetError );
393                         }
394                 }
395         };
396
397         /**
398          * wp.customize.Widgets.WidgetControl
399          *
400          * Customizer control for widgets.
401          * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
402          *
403          * @constructor
404          * @augments wp.customize.Control
405          */
406         api.Widgets.WidgetControl = api.Control.extend({
407                 defaultExpandedArguments: {
408                         duration: 'fast',
409                         completeCallback: $.noop
410                 },
411
412                 /**
413                  * @since 4.1.0
414                  */
415                 initialize: function ( id, options ) {
416                         var control = this;
417                         api.Control.prototype.initialize.call( control, id, options );
418                         control.expanded = new api.Value();
419                         control.expandedArgumentsQueue = [];
420                         control.expanded.bind( function ( expanded ) {
421                                 var args = control.expandedArgumentsQueue.shift();
422                                 args = $.extend( {}, control.defaultExpandedArguments, args );
423                                 control.onChangeExpanded( expanded, args );
424                         });
425                         control.expanded.set( false );
426                 },
427
428                 /**
429                  * Set up the control
430                  */
431                 ready: function() {
432                         this._setupModel();
433                         this._setupWideWidget();
434                         this._setupControlToggle();
435                         this._setupWidgetTitle();
436                         this._setupReorderUI();
437                         this._setupHighlightEffects();
438                         this._setupUpdateUI();
439                         this._setupRemoveUI();
440
441                         /*
442                          * Trigger widget-added event so that plugins can attach any event
443                          * listeners and dynamic UI elements.
444                          */
445                         $( document ).trigger( 'widget-added', [ this.container.find( '.widget:first' ) ] );
446                 },
447
448                 /**
449                  * Handle changes to the setting
450                  */
451                 _setupModel: function() {
452                         var self = this, rememberSavedWidgetId;
453
454                         api.Widgets.savedWidgetIds = api.Widgets.savedWidgetIds || [];
455
456                         // Remember saved widgets so we know which to trash (move to inactive widgets sidebar)
457                         rememberSavedWidgetId = function() {
458                                 api.Widgets.savedWidgetIds[self.params.widget_id] = true;
459                         };
460                         api.bind( 'ready', rememberSavedWidgetId );
461                         api.bind( 'saved', rememberSavedWidgetId );
462
463                         this._updateCount = 0;
464                         this.isWidgetUpdating = false;
465                         this.liveUpdateMode = true;
466
467                         // Update widget whenever model changes
468                         this.setting.bind( function( to, from ) {
469                                 if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) {
470                                         self.updateWidget( { instance: to } );
471                                 }
472                         } );
473                 },
474
475                 /**
476                  * Add special behaviors for wide widget controls
477                  */
478                 _setupWideWidget: function() {
479                         var self = this, $widgetInside, $widgetForm, $customizeSidebar,
480                                 $themeControlsContainer, positionWidget;
481
482                         if ( ! this.params.is_wide ) {
483                                 return;
484                         }
485
486                         $widgetInside = this.container.find( '.widget-inside' );
487                         $widgetForm = $widgetInside.find( '> .form' );
488                         $customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' );
489                         this.container.addClass( 'wide-widget-control' );
490
491                         this.container.find( '.widget-content:first' ).css( {
492                                 'max-width': this.params.width,
493                                 'min-height': this.params.height
494                         } );
495
496                         /**
497                          * Keep the widget-inside positioned so the top of fixed-positioned
498                          * element is at the same top position as the widget-top. When the
499                          * widget-top is scrolled out of view, keep the widget-top in view;
500                          * likewise, don't allow the widget to drop off the bottom of the window.
501                          * If a widget is too tall to fit in the window, don't let the height
502                          * exceed the window height so that the contents of the widget control
503                          * will become scrollable (overflow:auto).
504                          */
505                         positionWidget = function() {
506                                 var offsetTop = self.container.offset().top,
507                                         windowHeight = $( window ).height(),
508                                         formHeight = $widgetForm.outerHeight(),
509                                         top;
510                                 $widgetInside.css( 'max-height', windowHeight );
511                                 top = Math.max(
512                                         0, // prevent top from going off screen
513                                         Math.min(
514                                                 Math.max( offsetTop, 0 ), // distance widget in panel is from top of screen
515                                                 windowHeight - formHeight // flush up against bottom of screen
516                                         )
517                                 );
518                                 $widgetInside.css( 'top', top );
519                         };
520
521                         $themeControlsContainer = $( '#customize-theme-controls' );
522                         this.container.on( 'expand', function() {
523                                 positionWidget();
524                                 $customizeSidebar.on( 'scroll', positionWidget );
525                                 $( window ).on( 'resize', positionWidget );
526                                 $themeControlsContainer.on( 'expanded collapsed', positionWidget );
527                         } );
528                         this.container.on( 'collapsed', function() {
529                                 $customizeSidebar.off( 'scroll', positionWidget );
530                                 $( window ).off( 'resize', positionWidget );
531                                 $themeControlsContainer.off( 'expanded collapsed', positionWidget );
532                         } );
533
534                         // Reposition whenever a sidebar's widgets are changed
535                         api.each( function( setting ) {
536                                 if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) {
537                                         setting.bind( function() {
538                                                 if ( self.container.hasClass( 'expanded' ) ) {
539                                                         positionWidget();
540                                                 }
541                                         } );
542                                 }
543                         } );
544                 },
545
546                 /**
547                  * Show/hide the control when clicking on the form title, when clicking
548                  * the close button
549                  */
550                 _setupControlToggle: function() {
551                         var self = this, $closeBtn;
552
553                         this.container.find( '.widget-top' ).on( 'click', function( e ) {
554                                 e.preventDefault();
555                                 var sidebarWidgetsControl = self.getSidebarWidgetsControl();
556                                 if ( sidebarWidgetsControl.isReordering ) {
557                                         return;
558                                 }
559                                 self.expanded( ! self.expanded() );
560                         } );
561
562                         $closeBtn = this.container.find( '.widget-control-close' );
563                         $closeBtn.on( 'click', function( e ) {
564                                 e.preventDefault();
565                                 self.collapse();
566                                 self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility
567                         } );
568                 },
569
570                 /**
571                  * Update the title of the form if a title field is entered
572                  */
573                 _setupWidgetTitle: function() {
574                         var self = this, updateTitle;
575
576                         updateTitle = function() {
577                                 var title = self.setting().title,
578                                         inWidgetTitle = self.container.find( '.in-widget-title' );
579
580                                 if ( title ) {
581                                         inWidgetTitle.text( ': ' + title );
582                                 } else {
583                                         inWidgetTitle.text( '' );
584                                 }
585                         };
586                         this.setting.bind( updateTitle );
587                         updateTitle();
588                 },
589
590                 /**
591                  * Set up the widget-reorder-nav
592                  */
593                 _setupReorderUI: function() {
594                         var self = this, selectSidebarItem, $moveWidgetArea,
595                                 $reorderNav, updateAvailableSidebars;
596
597                         /**
598                          * select the provided sidebar list item in the move widget area
599                          *
600                          * @param {jQuery} li
601                          */
602                         selectSidebarItem = function( li ) {
603                                 li.siblings( '.selected' ).removeClass( 'selected' );
604                                 li.addClass( 'selected' );
605                                 var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id );
606                                 self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar );
607                         };
608
609                         /**
610                          * Add the widget reordering elements to the widget control
611                          */
612                         this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) );
613                         $moveWidgetArea = $(
614                                 _.template( api.Widgets.data.tpl.moveWidgetArea, {
615                                         sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' )
616                                 } )
617                         );
618                         this.container.find( '.widget-top' ).after( $moveWidgetArea );
619
620                         /**
621                          * Update available sidebars when their rendered state changes
622                          */
623                         updateAvailableSidebars = function() {
624                                 var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem;
625
626                                 selfSidebarItem = $sidebarItems.filter( function(){
627                                         return $( this ).data( 'id' ) === self.params.sidebar_id;
628                                 } );
629
630                                 $sidebarItems.each( function() {
631                                         var li = $( this ),
632                                                 sidebarId,
633                                                 sidebar;
634
635                                         sidebarId = li.data( 'id' );
636                                         sidebar = api.Widgets.registeredSidebars.get( sidebarId );
637
638                                         li.toggle( sidebar.get( 'is_rendered' ) );
639
640                                         if ( li.hasClass( 'selected' ) && ! sidebar.get( 'is_rendered' ) ) {
641                                                 selectSidebarItem( selfSidebarItem );
642                                         }
643                                 } );
644                         };
645
646                         updateAvailableSidebars();
647                         api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars );
648
649                         /**
650                          * Handle clicks for up/down/move on the reorder nav
651                          */
652                         $reorderNav = this.container.find( '.widget-reorder-nav' );
653                         $reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() {
654                                 $( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' );
655                         } ).on( 'click keypress', function( event ) {
656                                 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
657                                         return;
658                                 }
659                                 $( this ).focus();
660
661                                 if ( $( this ).is( '.move-widget' ) ) {
662                                         self.toggleWidgetMoveArea();
663                                 } else {
664                                         var isMoveDown = $( this ).is( '.move-widget-down' ),
665                                                 isMoveUp = $( this ).is( '.move-widget-up' ),
666                                                 i = self.getWidgetSidebarPosition();
667
668                                         if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) {
669                                                 return;
670                                         }
671
672                                         if ( isMoveUp ) {
673                                                 self.moveUp();
674                                                 $( '#screen-reader-messages' ).text( l10n.widgetMovedUp );
675                                         } else {
676                                                 self.moveDown();
677                                                 $( '#screen-reader-messages' ).text( l10n.widgetMovedDown );
678                                         }
679
680                                         $( this ).focus(); // re-focus after the container was moved
681                                 }
682                         } );
683
684                         /**
685                          * Handle selecting a sidebar to move to
686                          */
687                         this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( e ) {
688                                 if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
689                                         return;
690                                 }
691                                 e.preventDefault();
692                                 selectSidebarItem( $( this ) );
693                         } );
694
695                         /**
696                          * Move widget to another sidebar
697                          */
698                         this.container.find( '.move-widget-btn' ).click( function() {
699                                 self.getSidebarWidgetsControl().toggleReordering( false );
700
701                                 var oldSidebarId = self.params.sidebar_id,
702                                         newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ),
703                                         oldSidebarWidgetsSetting, newSidebarWidgetsSetting,
704                                         oldSidebarWidgetIds, newSidebarWidgetIds, i;
705
706                                 oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' );
707                                 newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' );
708                                 oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() );
709                                 newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() );
710
711                                 i = self.getWidgetSidebarPosition();
712                                 oldSidebarWidgetIds.splice( i, 1 );
713                                 newSidebarWidgetIds.push( self.params.widget_id );
714
715                                 oldSidebarWidgetsSetting( oldSidebarWidgetIds );
716                                 newSidebarWidgetsSetting( newSidebarWidgetIds );
717
718                                 self.focus();
719                         } );
720                 },
721
722                 /**
723                  * Highlight widgets in preview when interacted with in the Customizer
724                  */
725                 _setupHighlightEffects: function() {
726                         var self = this;
727
728                         // Highlight whenever hovering or clicking over the form
729                         this.container.on( 'mouseenter click', function() {
730                                 self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
731                         } );
732
733                         // Highlight when the setting is updated
734                         this.setting.bind( function() {
735                                 self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
736                         } );
737                 },
738
739                 /**
740                  * Set up event handlers for widget updating
741                  */
742                 _setupUpdateUI: function() {
743                         var self = this, $widgetRoot, $widgetContent,
744                                 $saveBtn, updateWidgetDebounced, formSyncHandler;
745
746                         $widgetRoot = this.container.find( '.widget:first' );
747                         $widgetContent = $widgetRoot.find( '.widget-content:first' );
748
749                         // Configure update button
750                         $saveBtn = this.container.find( '.widget-control-save' );
751                         $saveBtn.val( l10n.saveBtnLabel );
752                         $saveBtn.attr( 'title', l10n.saveBtnTooltip );
753                         $saveBtn.removeClass( 'button-primary' ).addClass( 'button-secondary' );
754                         $saveBtn.on( 'click', function( e ) {
755                                 e.preventDefault();
756                                 self.updateWidget( { disable_form: true } ); // @todo disable_form is unused?
757                         } );
758
759                         updateWidgetDebounced = _.debounce( function() {
760                                 self.updateWidget();
761                         }, 250 );
762
763                         // Trigger widget form update when hitting Enter within an input
764                         $widgetContent.on( 'keydown', 'input', function( e ) {
765                                 if ( 13 === e.which ) { // Enter
766                                         e.preventDefault();
767                                         self.updateWidget( { ignoreActiveElement: true } );
768                                 }
769                         } );
770
771                         // Handle widgets that support live previews
772                         $widgetContent.on( 'change input propertychange', ':input', function( e ) {
773                                 if ( self.liveUpdateMode ) {
774                                         if ( e.type === 'change' ) {
775                                                 self.updateWidget();
776                                         } else if ( this.checkValidity && this.checkValidity() ) {
777                                                 updateWidgetDebounced();
778                                         }
779                                 }
780                         } );
781
782                         // Remove loading indicators when the setting is saved and the preview updates
783                         this.setting.previewer.channel.bind( 'synced', function() {
784                                 self.container.removeClass( 'previewer-loading' );
785                         } );
786
787                         api.previewer.bind( 'widget-updated', function( updatedWidgetId ) {
788                                 if ( updatedWidgetId === self.params.widget_id ) {
789                                         self.container.removeClass( 'previewer-loading' );
790                                 }
791                         } );
792
793                         formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ];
794                         if ( formSyncHandler ) {
795                                 $( document ).on( 'widget-synced', function( e, widget ) {
796                                         if ( $widgetRoot.is( widget ) ) {
797                                                 formSyncHandler.apply( document, arguments );
798                                         }
799                                 } );
800                         }
801                 },
802
803                 /**
804                  * Update widget control to indicate whether it is currently rendered.
805                  *
806                  * Overrides api.Control.toggle()
807                  *
808                  * @since 4.1.0
809                  *
810                  * @param {Boolean}   active
811                  * @param {Object}    args
812                  * @param {Callback}  args.completeCallback
813                  */
814                 onChangeActive: function ( active, args ) {
815                         // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments
816                         this.container.toggleClass( 'widget-rendered', active );
817                         if ( args.completeCallback ) {
818                                 args.completeCallback();
819                         }
820                 },
821
822                 /**
823                  * Set up event handlers for widget removal
824                  */
825                 _setupRemoveUI: function() {
826                         var self = this, $removeBtn, replaceDeleteWithRemove;
827
828                         // Configure remove button
829                         $removeBtn = this.container.find( 'a.widget-control-remove' );
830                         $removeBtn.on( 'click', function( e ) {
831                                 e.preventDefault();
832
833                                 // Find an adjacent element to add focus to when this widget goes away
834                                 var $adjacentFocusTarget;
835                                 if ( self.container.next().is( '.customize-control-widget_form' ) ) {
836                                         $adjacentFocusTarget = self.container.next().find( '.widget-action:first' );
837                                 } else if ( self.container.prev().is( '.customize-control-widget_form' ) ) {
838                                         $adjacentFocusTarget = self.container.prev().find( '.widget-action:first' );
839                                 } else {
840                                         $adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' );
841                                 }
842
843                                 self.container.slideUp( function() {
844                                         var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ),
845                                                 sidebarWidgetIds, i;
846
847                                         if ( ! sidebarsWidgetsControl ) {
848                                                 return;
849                                         }
850
851                                         sidebarWidgetIds = sidebarsWidgetsControl.setting().slice();
852                                         i = _.indexOf( sidebarWidgetIds, self.params.widget_id );
853                                         if ( -1 === i ) {
854                                                 return;
855                                         }
856
857                                         sidebarWidgetIds.splice( i, 1 );
858                                         sidebarsWidgetsControl.setting( sidebarWidgetIds );
859
860                                         $adjacentFocusTarget.focus(); // keyboard accessibility
861                                 } );
862                         } );
863
864                         replaceDeleteWithRemove = function() {
865                                 $removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the link as "Delete"
866                                 $removeBtn.attr( 'title', l10n.removeBtnTooltip );
867                         };
868
869                         if ( this.params.is_new ) {
870                                 api.bind( 'saved', replaceDeleteWithRemove );
871                         } else {
872                                 replaceDeleteWithRemove();
873                         }
874                 },
875
876                 /**
877                  * Find all inputs in a widget container that should be considered when
878                  * comparing the loaded form with the sanitized form, whose fields will
879                  * be aligned to copy the sanitized over. The elements returned by this
880                  * are passed into this._getInputsSignature(), and they are iterated
881                  * over when copying sanitized values over to the the form loaded.
882                  *
883                  * @param {jQuery} container element in which to look for inputs
884                  * @returns {jQuery} inputs
885                  * @private
886                  */
887                 _getInputs: function( container ) {
888                         return $( container ).find( ':input[name]' );
889                 },
890
891                 /**
892                  * Iterate over supplied inputs and create a signature string for all of them together.
893                  * This string can be used to compare whether or not the form has all of the same fields.
894                  *
895                  * @param {jQuery} inputs
896                  * @returns {string}
897                  * @private
898                  */
899                 _getInputsSignature: function( inputs ) {
900                         var inputsSignatures = _( inputs ).map( function( input ) {
901                                 var $input = $( input ), signatureParts;
902
903                                 if ( $input.is( ':checkbox, :radio' ) ) {
904                                         signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ];
905                                 } else {
906                                         signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ];
907                                 }
908
909                                 return signatureParts.join( ',' );
910                         } );
911
912                         return inputsSignatures.join( ';' );
913                 },
914
915                 /**
916                  * Get the property that represents the state of an input.
917                  *
918                  * @param {jQuery|DOMElement} input
919                  * @returns {string}
920                  * @private
921                  */
922                 _getInputStatePropertyName: function( input ) {
923                         var $input = $( input );
924
925                         if ( $input.is( ':radio, :checkbox' ) ) {
926                                 return 'checked';
927                         } else {
928                                 return 'value';
929                         }
930                 },
931
932                 /***********************************************************************
933                  * Begin public API methods
934                  **********************************************************************/
935
936                 /**
937                  * @return {wp.customize.controlConstructor.sidebar_widgets[]}
938                  */
939                 getSidebarWidgetsControl: function() {
940                         var settingId, sidebarWidgetsControl;
941
942                         settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']';
943                         sidebarWidgetsControl = api.control( settingId );
944
945                         if ( ! sidebarWidgetsControl ) {
946                                 return;
947                         }
948
949                         return sidebarWidgetsControl;
950                 },
951
952                 /**
953                  * Submit the widget form via Ajax and get back the updated instance,
954                  * along with the new widget control form to render.
955                  *
956                  * @param {object} [args]
957                  * @param {Object|null} [args.instance=null]  When the model changes, the instance is sent here; otherwise, the inputs from the form are used
958                  * @param {Function|null} [args.complete=null]  Function which is called when the request finishes. Context is bound to the control. First argument is any error. Following arguments are for success.
959                  * @param {Boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element.
960                  */
961                 updateWidget: function( args ) {
962                         var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent,
963                                 updateNumber, params, data, $inputs, processing, jqxhr, isChanged;
964
965                         args = $.extend( {
966                                 instance: null,
967                                 complete: null,
968                                 ignoreActiveElement: false
969                         }, args );
970
971                         instanceOverride = args.instance;
972                         completeCallback = args.complete;
973
974                         this._updateCount += 1;
975                         updateNumber = this._updateCount;
976
977                         $widgetRoot = this.container.find( '.widget:first' );
978                         $widgetContent = $widgetRoot.find( '.widget-content:first' );
979
980                         // Remove a previous error message
981                         $widgetContent.find( '.widget-error' ).remove();
982
983                         this.container.addClass( 'widget-form-loading' );
984                         this.container.addClass( 'previewer-loading' );
985                         processing = api.state( 'processing' );
986                         processing( processing() + 1 );
987
988                         if ( ! this.liveUpdateMode ) {
989                                 this.container.addClass( 'widget-form-disabled' );
990                         }
991
992                         params = {};
993                         params.action = 'update-widget';
994                         params.wp_customize = 'on';
995                         params.nonce = api.Widgets.data.nonce;
996                         params.theme = api.settings.theme.stylesheet;
997
998                         data = $.param( params );
999                         $inputs = this._getInputs( $widgetContent );
1000
1001                         // Store the value we're submitting in data so that when the response comes back,
1002                         // we know if it got sanitized; if there is no difference in the sanitized value,
1003                         // then we do not need to touch the UI and mess up the user's ongoing editing.
1004                         $inputs.each( function() {
1005                                 var input = $( this ),
1006                                         property = self._getInputStatePropertyName( this );
1007                                 input.data( 'state' + updateNumber, input.prop( property ) );
1008                         } );
1009
1010                         if ( instanceOverride ) {
1011                                 data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } );
1012                         } else {
1013                                 data += '&' + $inputs.serialize();
1014                         }
1015                         data += '&' + $widgetContent.find( '~ :input' ).serialize();
1016
1017                         jqxhr = $.post( wp.ajax.settings.url, data );
1018
1019                         jqxhr.done( function( r ) {
1020                                 var message, sanitizedForm,     $sanitizedInputs, hasSameInputsInResponse,
1021                                         isLiveUpdateAborted = false;
1022
1023                                 // Check if the user is logged out.
1024                                 if ( '0' === r ) {
1025                                         api.previewer.preview.iframe.hide();
1026                                         api.previewer.login().done( function() {
1027                                                 self.updateWidget( args );
1028                                                 api.previewer.preview.iframe.show();
1029                                         } );
1030                                         return;
1031                                 }
1032
1033                                 // Check for cheaters.
1034                                 if ( '-1' === r ) {
1035                                         api.previewer.cheatin();
1036                                         return;
1037                                 }
1038
1039                                 if ( r.success ) {
1040                                         sanitizedForm = $( '<div>' + r.data.form + '</div>' );
1041                                         $sanitizedInputs = self._getInputs( sanitizedForm );
1042                                         hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs );
1043
1044                                         // Restore live update mode if sanitized fields are now aligned with the existing fields
1045                                         if ( hasSameInputsInResponse && ! self.liveUpdateMode ) {
1046                                                 self.liveUpdateMode = true;
1047                                                 self.container.removeClass( 'widget-form-disabled' );
1048                                                 self.container.find( 'input[name="savewidget"]' ).hide();
1049                                         }
1050
1051                                         // Sync sanitized field states to existing fields if they are aligned
1052                                         if ( hasSameInputsInResponse && self.liveUpdateMode ) {
1053                                                 $inputs.each( function( i ) {
1054                                                         var $input = $( this ),
1055                                                                 $sanitizedInput = $( $sanitizedInputs[i] ),
1056                                                                 property = self._getInputStatePropertyName( this ),
1057                                                                 submittedState, sanitizedState, canUpdateState;
1058
1059                                                         submittedState = $input.data( 'state' + updateNumber );
1060                                                         sanitizedState = $sanitizedInput.prop( property );
1061                                                         $input.data( 'sanitized', sanitizedState );
1062
1063                                                         canUpdateState = ( submittedState !== sanitizedState && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) )   );
1064                                                         if ( canUpdateState ) {
1065                                                                 $input.prop( property, sanitizedState );
1066                                                         }
1067                                                 } );
1068
1069                                                 $( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] );
1070
1071                                         // Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled
1072                                         } else if ( self.liveUpdateMode ) {
1073                                                 self.liveUpdateMode = false;
1074                                                 self.container.find( 'input[name="savewidget"]' ).show();
1075                                                 isLiveUpdateAborted = true;
1076
1077                                         // Otherwise, replace existing form with the sanitized form
1078                                         } else {
1079                                                 $widgetContent.html( r.data.form );
1080
1081                                                 self.container.removeClass( 'widget-form-disabled' );
1082
1083                                                 $( document ).trigger( 'widget-updated', [ $widgetRoot ] );
1084                                         }
1085
1086                                         /**
1087                                          * If the old instance is identical to the new one, there is nothing new
1088                                          * needing to be rendered, and so we can preempt the event for the
1089                                          * preview finishing loading.
1090                                          */
1091                                         isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance );
1092                                         if ( isChanged ) {
1093                                                 self.isWidgetUpdating = true; // suppress triggering another updateWidget
1094                                                 self.setting( r.data.instance );
1095                                                 self.isWidgetUpdating = false;
1096                                         } else {
1097                                                 // no change was made, so stop the spinner now instead of when the preview would updates
1098                                                 self.container.removeClass( 'previewer-loading' );
1099                                         }
1100
1101                                         if ( completeCallback ) {
1102                                                 completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } );
1103                                         }
1104                                 } else {
1105                                         // General error message
1106                                         message = l10n.error;
1107
1108                                         if ( r.data && r.data.message ) {
1109                                                 message = r.data.message;
1110                                         }
1111
1112                                         if ( completeCallback ) {
1113                                                 completeCallback.call( self, message );
1114                                         } else {
1115                                                 $widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' );
1116                                         }
1117                                 }
1118                         } );
1119
1120                         jqxhr.fail( function( jqXHR, textStatus ) {
1121                                 if ( completeCallback ) {
1122                                         completeCallback.call( self, textStatus );
1123                                 }
1124                         } );
1125
1126                         jqxhr.always( function() {
1127                                 self.container.removeClass( 'widget-form-loading' );
1128
1129                                 $inputs.each( function() {
1130                                         $( this ).removeData( 'state' + updateNumber );
1131                                 } );
1132
1133                                 processing( processing() - 1 );
1134                         } );
1135                 },
1136
1137                 /**
1138                  * Expand the accordion section containing a control
1139                  */
1140                 expandControlSection: function() {
1141                         api.Control.prototype.expand.call( this );
1142                 },
1143
1144                 /**
1145                  * @since 4.1.0
1146                  *
1147                  * @param {Boolean} expanded
1148                  * @param {Object} [params]
1149                  * @returns {Boolean} false if state already applied
1150                  */
1151                 _toggleExpanded: api.Section.prototype._toggleExpanded,
1152
1153                 /**
1154                  * @since 4.1.0
1155                  *
1156                  * @param {Object} [params]
1157                  * @returns {Boolean} false if already expanded
1158                  */
1159                 expand: api.Section.prototype.expand,
1160
1161                 /**
1162                  * Expand the widget form control
1163                  *
1164                  * @deprecated 4.1.0 Use this.expand() instead.
1165                  */
1166                 expandForm: function() {
1167                         this.expand();
1168                 },
1169
1170                 /**
1171                  * @since 4.1.0
1172                  *
1173                  * @param {Object} [params]
1174                  * @returns {Boolean} false if already collapsed
1175                  */
1176                 collapse: api.Section.prototype.collapse,
1177
1178                 /**
1179                  * Collapse the widget form control
1180                  *
1181                  * @deprecated 4.1.0 Use this.collapse() instead.
1182                  */
1183                 collapseForm: function() {
1184                         this.collapse();
1185                 },
1186
1187                 /**
1188                  * Expand or collapse the widget control
1189                  *
1190                  * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
1191                  *
1192                  * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility
1193                  */
1194                 toggleForm: function( showOrHide ) {
1195                         if ( typeof showOrHide === 'undefined' ) {
1196                                 showOrHide = ! this.expanded();
1197                         }
1198                         this.expanded( showOrHide );
1199                 },
1200
1201                 /**
1202                  * Respond to change in the expanded state.
1203                  *
1204                  * @param {Boolean} expanded
1205                  * @param {Object} args  merged on top of this.defaultActiveArguments
1206                  */
1207                 onChangeExpanded: function ( expanded, args ) {
1208                         var self = this, $widget, $inside, complete, prevComplete;
1209
1210                         // If the expanded state is unchanged only manipulate container expanded states
1211                         if ( args.unchanged ) {
1212                                 if ( expanded ) {
1213                                         api.Control.prototype.expand.call( self, {
1214                                                 completeCallback:  args.completeCallback
1215                                         });
1216                                 }
1217                                 return;
1218                         }
1219
1220                         $widget = this.container.find( 'div.widget:first' );
1221                         $inside = $widget.find( '.widget-inside:first' );
1222
1223                         if ( expanded ) {
1224
1225                                 self.expandControlSection();
1226
1227                                 // Close all other widget controls before expanding this one
1228                                 api.control.each( function( otherControl ) {
1229                                         if ( self.params.type === otherControl.params.type && self !== otherControl ) {
1230                                                 otherControl.collapse();
1231                                         }
1232                                 } );
1233
1234                                 complete = function() {
1235                                         self.container.removeClass( 'expanding' );
1236                                         self.container.addClass( 'expanded' );
1237                                         self.container.trigger( 'expanded' );
1238                                 };
1239                                 if ( args.completeCallback ) {
1240                                         prevComplete = complete;
1241                                         complete = function () {
1242                                                 prevComplete();
1243                                                 args.completeCallback();
1244                                         };
1245                                 }
1246
1247                                 if ( self.params.is_wide ) {
1248                                         $inside.fadeIn( args.duration, complete );
1249                                 } else {
1250                                         $inside.slideDown( args.duration, complete );
1251                                 }
1252
1253                                 self.container.trigger( 'expand' );
1254                                 self.container.addClass( 'expanding' );
1255                         } else {
1256
1257                                 complete = function() {
1258                                         self.container.removeClass( 'collapsing' );
1259                                         self.container.removeClass( 'expanded' );
1260                                         self.container.trigger( 'collapsed' );
1261                                 };
1262                                 if ( args.completeCallback ) {
1263                                         prevComplete = complete;
1264                                         complete = function () {
1265                                                 prevComplete();
1266                                                 args.completeCallback();
1267                                         };
1268                                 }
1269
1270                                 self.container.trigger( 'collapse' );
1271                                 self.container.addClass( 'collapsing' );
1272
1273                                 if ( self.params.is_wide ) {
1274                                         $inside.fadeOut( args.duration, complete );
1275                                 } else {
1276                                         $inside.slideUp( args.duration, function() {
1277                                                 $widget.css( { width:'', margin:'' } );
1278                                                 complete();
1279                                         } );
1280                                 }
1281                         }
1282                 },
1283
1284                 /**
1285                  * Get the position (index) of the widget in the containing sidebar
1286                  *
1287                  * @returns {Number}
1288                  */
1289                 getWidgetSidebarPosition: function() {
1290                         var sidebarWidgetIds, position;
1291
1292                         sidebarWidgetIds = this.getSidebarWidgetsControl().setting();
1293                         position = _.indexOf( sidebarWidgetIds, this.params.widget_id );
1294
1295                         if ( position === -1 ) {
1296                                 return;
1297                         }
1298
1299                         return position;
1300                 },
1301
1302                 /**
1303                  * Move widget up one in the sidebar
1304                  */
1305                 moveUp: function() {
1306                         this._moveWidgetByOne( -1 );
1307                 },
1308
1309                 /**
1310                  * Move widget up one in the sidebar
1311                  */
1312                 moveDown: function() {
1313                         this._moveWidgetByOne( 1 );
1314                 },
1315
1316                 /**
1317                  * @private
1318                  *
1319                  * @param {Number} offset 1|-1
1320                  */
1321                 _moveWidgetByOne: function( offset ) {
1322                         var i, sidebarWidgetsSetting, sidebarWidgetIds, adjacentWidgetId;
1323
1324                         i = this.getWidgetSidebarPosition();
1325
1326                         sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting;
1327                         sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // clone
1328                         adjacentWidgetId = sidebarWidgetIds[i + offset];
1329                         sidebarWidgetIds[i + offset] = this.params.widget_id;
1330                         sidebarWidgetIds[i] = adjacentWidgetId;
1331
1332                         sidebarWidgetsSetting( sidebarWidgetIds );
1333                 },
1334
1335                 /**
1336                  * Toggle visibility of the widget move area
1337                  *
1338                  * @param {Boolean} [showOrHide]
1339                  */
1340                 toggleWidgetMoveArea: function( showOrHide ) {
1341                         var self = this, $moveWidgetArea;
1342
1343                         $moveWidgetArea = this.container.find( '.move-widget-area' );
1344
1345                         if ( typeof showOrHide === 'undefined' ) {
1346                                 showOrHide = ! $moveWidgetArea.hasClass( 'active' );
1347                         }
1348
1349                         if ( showOrHide ) {
1350                                 // reset the selected sidebar
1351                                 $moveWidgetArea.find( '.selected' ).removeClass( 'selected' );
1352
1353                                 $moveWidgetArea.find( 'li' ).filter( function() {
1354                                         return $( this ).data( 'id' ) === self.params.sidebar_id;
1355                                 } ).addClass( 'selected' );
1356
1357                                 this.container.find( '.move-widget-btn' ).prop( 'disabled', true );
1358                         }
1359
1360                         $moveWidgetArea.toggleClass( 'active', showOrHide );
1361                 },
1362
1363                 /**
1364                  * Highlight the widget control and section
1365                  */
1366                 highlightSectionAndControl: function() {
1367                         var $target;
1368
1369                         if ( this.container.is( ':hidden' ) ) {
1370                                 $target = this.container.closest( '.control-section' );
1371                         } else {
1372                                 $target = this.container;
1373                         }
1374
1375                         $( '.highlighted' ).removeClass( 'highlighted' );
1376                         $target.addClass( 'highlighted' );
1377
1378                         setTimeout( function() {
1379                                 $target.removeClass( 'highlighted' );
1380                         }, 500 );
1381                 }
1382         } );
1383
1384         /**
1385          * wp.customize.Widgets.SidebarSection
1386          *
1387          * Customizer section representing a widget area widget
1388          *
1389          * @since 4.1.0
1390          */
1391         api.Widgets.SidebarSection = api.Section.extend({
1392
1393                 /**
1394                  * Sync the section's active state back to the Backbone model's is_rendered attribute
1395                  *
1396                  * @since 4.1.0
1397                  */
1398                 ready: function () {
1399                         var section = this, registeredSidebar;
1400                         api.Section.prototype.ready.call( this );
1401                         registeredSidebar = api.Widgets.registeredSidebars.get( section.params.sidebarId );
1402                         section.active.bind( function ( active ) {
1403                                 registeredSidebar.set( 'is_rendered', active );
1404                         });
1405                         registeredSidebar.set( 'is_rendered', section.active() );
1406                 }
1407         });
1408
1409         /**
1410          * wp.customize.Widgets.SidebarControl
1411          *
1412          * Customizer control for widgets.
1413          * Note that 'sidebar_widgets' must match the WP_Widget_Area_Customize_Control::$type
1414          *
1415          * @since 3.9.0
1416          *
1417          * @constructor
1418          * @augments wp.customize.Control
1419          */
1420         api.Widgets.SidebarControl = api.Control.extend({
1421
1422                 /**
1423                  * Set up the control
1424                  */
1425                 ready: function() {
1426                         this.$controlSection = this.container.closest( '.control-section' );
1427                         this.$sectionContent = this.container.closest( '.accordion-section-content' );
1428
1429                         this._setupModel();
1430                         this._setupSortable();
1431                         this._setupAddition();
1432                         this._applyCardinalOrderClassNames();
1433                 },
1434
1435                 /**
1436                  * Update ordering of widget control forms when the setting is updated
1437                  */
1438                 _setupModel: function() {
1439                         var self = this;
1440
1441                         this.setting.bind( function( newWidgetIds, oldWidgetIds ) {
1442                                 var widgetFormControls, removedWidgetIds, priority;
1443
1444                                 removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds );
1445
1446                                 // Filter out any persistent widget IDs for widgets which have been deactivated
1447                                 newWidgetIds = _( newWidgetIds ).filter( function( newWidgetId ) {
1448                                         var parsedWidgetId = parseWidgetId( newWidgetId );
1449
1450                                         return !! api.Widgets.availableWidgets.findWhere( { id_base: parsedWidgetId.id_base } );
1451                                 } );
1452
1453                                 widgetFormControls = _( newWidgetIds ).map( function( widgetId ) {
1454                                         var widgetFormControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
1455
1456                                         if ( ! widgetFormControl ) {
1457                                                 widgetFormControl = self.addWidget( widgetId );
1458                                         }
1459
1460                                         return widgetFormControl;
1461                                 } );
1462
1463                                 // Sort widget controls to their new positions
1464                                 widgetFormControls.sort( function( a, b ) {
1465                                         var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ),
1466                                                 bIndex = _.indexOf( newWidgetIds, b.params.widget_id );
1467                                         return aIndex - bIndex;
1468                                 });
1469
1470                                 priority = 0;
1471                                 _( widgetFormControls ).each( function ( control ) {
1472                                         control.priority( priority );
1473                                         control.section( self.section() );
1474                                         priority += 1;
1475                                 });
1476                                 self.priority( priority ); // Make sure sidebar control remains at end
1477
1478                                 // Re-sort widget form controls (including widgets form other sidebars newly moved here)
1479                                 self._applyCardinalOrderClassNames();
1480
1481                                 // If the widget was dragged into the sidebar, make sure the sidebar_id param is updated
1482                                 _( widgetFormControls ).each( function( widgetFormControl ) {
1483                                         widgetFormControl.params.sidebar_id = self.params.sidebar_id;
1484                                 } );
1485
1486                                 // Cleanup after widget removal
1487                                 _( removedWidgetIds ).each( function( removedWidgetId ) {
1488
1489                                         // Using setTimeout so that when moving a widget to another sidebar, the other sidebars_widgets settings get a chance to update
1490                                         setTimeout( function() {
1491                                                 var removedControl, wasDraggedToAnotherSidebar, inactiveWidgets, removedIdBase,
1492                                                         widget, isPresentInAnotherSidebar = false;
1493
1494                                                 // Check if the widget is in another sidebar
1495                                                 api.each( function( otherSetting ) {
1496                                                         if ( otherSetting.id === self.setting.id || 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) || otherSetting.id === 'sidebars_widgets[wp_inactive_widgets]' ) {
1497                                                                 return;
1498                                                         }
1499
1500                                                         var otherSidebarWidgets = otherSetting(), i;
1501
1502                                                         i = _.indexOf( otherSidebarWidgets, removedWidgetId );
1503                                                         if ( -1 !== i ) {
1504                                                                 isPresentInAnotherSidebar = true;
1505                                                         }
1506                                                 } );
1507
1508                                                 // If the widget is present in another sidebar, abort!
1509                                                 if ( isPresentInAnotherSidebar ) {
1510                                                         return;
1511                                                 }
1512
1513                                                 removedControl = api.Widgets.getWidgetFormControlForWidget( removedWidgetId );
1514
1515                                                 // Detect if widget control was dragged to another sidebar
1516                                                 wasDraggedToAnotherSidebar = removedControl && $.contains( document, removedControl.container[0] ) && ! $.contains( self.$sectionContent[0], removedControl.container[0] );
1517
1518                                                 // Delete any widget form controls for removed widgets
1519                                                 if ( removedControl && ! wasDraggedToAnotherSidebar ) {
1520                                                         api.control.remove( removedControl.id );
1521                                                         removedControl.container.remove();
1522                                                 }
1523
1524                                                 // Move widget to inactive widgets sidebar (move it to trash) if has been previously saved
1525                                                 // This prevents the inactive widgets sidebar from overflowing with throwaway widgets
1526                                                 if ( api.Widgets.savedWidgetIds[removedWidgetId] ) {
1527                                                         inactiveWidgets = api.value( 'sidebars_widgets[wp_inactive_widgets]' )().slice();
1528                                                         inactiveWidgets.push( removedWidgetId );
1529                                                         api.value( 'sidebars_widgets[wp_inactive_widgets]' )( _( inactiveWidgets ).unique() );
1530                                                 }
1531
1532                                                 // Make old single widget available for adding again
1533                                                 removedIdBase = parseWidgetId( removedWidgetId ).id_base;
1534                                                 widget = api.Widgets.availableWidgets.findWhere( { id_base: removedIdBase } );
1535                                                 if ( widget && ! widget.get( 'is_multi' ) ) {
1536                                                         widget.set( 'is_disabled', false );
1537                                                 }
1538                                         } );
1539
1540                                 } );
1541                         } );
1542                 },
1543
1544                 /**
1545                  * Allow widgets in sidebar to be re-ordered, and for the order to be previewed
1546                  */
1547                 _setupSortable: function() {
1548                         var self = this;
1549
1550                         this.isReordering = false;
1551
1552                         /**
1553                          * Update widget order setting when controls are re-ordered
1554                          */
1555                         this.$sectionContent.sortable( {
1556                                 items: '> .customize-control-widget_form',
1557                                 handle: '.widget-top',
1558                                 axis: 'y',
1559                                 connectWith: '.accordion-section-content:has(.customize-control-sidebar_widgets)',
1560                                 update: function() {
1561                                         var widgetContainerIds = self.$sectionContent.sortable( 'toArray' ), widgetIds;
1562
1563                                         widgetIds = $.map( widgetContainerIds, function( widgetContainerId ) {
1564                                                 return $( '#' + widgetContainerId ).find( ':input[name=widget-id]' ).val();
1565                                         } );
1566
1567                                         self.setting( widgetIds );
1568                                 }
1569                         } );
1570
1571                         /**
1572                          * Expand other Customizer sidebar section when dragging a control widget over it,
1573                          * allowing the control to be dropped into another section
1574                          */
1575                         this.$controlSection.find( '.accordion-section-title' ).droppable({
1576                                 accept: '.customize-control-widget_form',
1577                                 over: function() {
1578                                         var section = api.section( self.section.get() );
1579                                         section.expand({
1580                                                 allowMultiple: true, // Prevent the section being dragged from to be collapsed
1581                                                 completeCallback: function () {
1582                                                         // @todo It is not clear when refreshPositions should be called on which sections, or if it is even needed
1583                                                         api.section.each( function ( otherSection ) {
1584                                                                 if ( otherSection.container.find( '.customize-control-sidebar_widgets' ).length ) {
1585                                                                         otherSection.container.find( '.accordion-section-content:first' ).sortable( 'refreshPositions' );
1586                                                                 }
1587                                                         } );
1588                                                 }
1589                                         });
1590                                 }
1591                         });
1592
1593                         /**
1594                          * Keyboard-accessible reordering
1595                          */
1596                         this.container.find( '.reorder-toggle' ).on( 'click keydown', function( event ) {
1597                                 if ( event.type === 'keydown' && ! ( event.which === 13 || event.which === 32 ) ) { // Enter or Spacebar
1598                                         return;
1599                                 }
1600
1601                                 self.toggleReordering( ! self.isReordering );
1602                         } );
1603                 },
1604
1605                 /**
1606                  * Set up UI for adding a new widget
1607                  */
1608                 _setupAddition: function() {
1609                         var self = this;
1610
1611                         this.container.find( '.add-new-widget' ).on( 'click keydown', function( event ) {
1612                                 if ( event.type === 'keydown' && ! ( event.which === 13 || event.which === 32 ) ) { // Enter or Spacebar
1613                                         return;
1614                                 }
1615
1616                                 if ( self.$sectionContent.hasClass( 'reordering' ) ) {
1617                                         return;
1618                                 }
1619
1620                                 if ( ! $( 'body' ).hasClass( 'adding-widget' ) ) {
1621                                         api.Widgets.availableWidgetsPanel.open( self );
1622                                 } else {
1623                                         api.Widgets.availableWidgetsPanel.close();
1624                                 }
1625                         } );
1626                 },
1627
1628                 /**
1629                  * Add classes to the widget_form controls to assist with styling
1630                  */
1631                 _applyCardinalOrderClassNames: function() {
1632                         var widgetControls = [];
1633                         _.each( this.setting(), function ( widgetId ) {
1634                                 var widgetControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
1635                                 if ( widgetControl ) {
1636                                         widgetControls.push( widgetControl );
1637                                 }
1638                         });
1639
1640                         if ( ! widgetControls.length ) {
1641                                 return;
1642                         }
1643
1644                         $( widgetControls ).each( function () {
1645                                 $( this.container )
1646                                         .removeClass( 'first-widget' )
1647                                         .removeClass( 'last-widget' )
1648                                         .find( '.move-widget-down, .move-widget-up' ).prop( 'tabIndex', 0 );
1649                         });
1650
1651                         _.first( widgetControls ).container
1652                                 .addClass( 'first-widget' )
1653                                 .find( '.move-widget-up' ).prop( 'tabIndex', -1 );
1654
1655                         _.last( widgetControls ).container
1656                                 .addClass( 'last-widget' )
1657                                 .find( '.move-widget-down' ).prop( 'tabIndex', -1 );
1658                 },
1659
1660
1661                 /***********************************************************************
1662                  * Begin public API methods
1663                  **********************************************************************/
1664
1665                 /**
1666                  * Enable/disable the reordering UI
1667                  *
1668                  * @param {Boolean} showOrHide to enable/disable reordering
1669                  *
1670                  * @todo We should have a reordering state instead and rename this to onChangeReordering
1671                  */
1672                 toggleReordering: function( showOrHide ) {
1673                         showOrHide = Boolean( showOrHide );
1674
1675                         if ( showOrHide === this.$sectionContent.hasClass( 'reordering' ) ) {
1676                                 return;
1677                         }
1678
1679                         this.isReordering = showOrHide;
1680                         this.$sectionContent.toggleClass( 'reordering', showOrHide );
1681
1682                         if ( showOrHide ) {
1683                                 _( this.getWidgetFormControls() ).each( function( formControl ) {
1684                                         formControl.collapse();
1685                                 } );
1686
1687                                 this.$sectionContent.find( '.first-widget .move-widget' ).focus();
1688                                 this.$sectionContent.find( '.add-new-widget' ).prop( 'tabIndex', -1 );
1689                         } else {
1690                                 this.$sectionContent.find( '.add-new-widget' ).prop( 'tabIndex', 0 );
1691                         }
1692                 },
1693
1694                 /**
1695                  * @return {wp.customize.controlConstructor.widget_form[]}
1696                  */
1697                 getWidgetFormControls: function() {
1698                         var formControls;
1699
1700                         formControls = _( this.setting() ).map( function( widgetId ) {
1701                                 var settingId = widgetIdToSettingId( widgetId ),
1702                                         formControl = api.control( settingId );
1703
1704                                 if ( ! formControl ) {
1705                                         return;
1706                                 }
1707
1708                                 return formControl;
1709                         } );
1710
1711                         return formControls;
1712                 },
1713
1714                 /**
1715                  * @param {string} widgetId or an id_base for adding a previously non-existing widget
1716                  * @returns {object|false} widget_form control instance, or false on error
1717                  */
1718                 addWidget: function( widgetId ) {
1719                         var self = this, controlHtml, $widget, controlType = 'widget_form', controlContainer, controlConstructor,
1720                                 parsedWidgetId = parseWidgetId( widgetId ),
1721                                 widgetNumber = parsedWidgetId.number,
1722                                 widgetIdBase = parsedWidgetId.id_base,
1723                                 widget = api.Widgets.availableWidgets.findWhere( {id_base: widgetIdBase} ),
1724                                 settingId, isExistingWidget, widgetFormControl, sidebarWidgets, settingArgs, setting;
1725
1726                         if ( ! widget ) {
1727                                 return false;
1728                         }
1729
1730                         if ( widgetNumber && ! widget.get( 'is_multi' ) ) {
1731                                 return false;
1732                         }
1733
1734                         // Set up new multi widget
1735                         if ( widget.get( 'is_multi' ) && ! widgetNumber ) {
1736                                 widget.set( 'multi_number', widget.get( 'multi_number' ) + 1 );
1737                                 widgetNumber = widget.get( 'multi_number' );
1738                         }
1739
1740                         controlHtml = $.trim( $( '#widget-tpl-' + widget.get( 'id' ) ).html() );
1741                         if ( widget.get( 'is_multi' ) ) {
1742                                 controlHtml = controlHtml.replace( /<[^<>]+>/g, function( m ) {
1743                                         return m.replace( /__i__|%i%/g, widgetNumber );
1744                                 } );
1745                         } else {
1746                                 widget.set( 'is_disabled', true ); // Prevent single widget from being added again now
1747                         }
1748
1749                         $widget = $( controlHtml );
1750
1751                         controlContainer = $( '<li/>' )
1752                                 .addClass( 'customize-control' )
1753                                 .addClass( 'customize-control-' + controlType )
1754                                 .append( $widget );
1755
1756                         // Remove icon which is visible inside the panel
1757                         controlContainer.find( '> .widget-icon' ).remove();
1758
1759                         if ( widget.get( 'is_multi' ) ) {
1760                                 controlContainer.find( 'input[name="widget_number"]' ).val( widgetNumber );
1761                                 controlContainer.find( 'input[name="multi_number"]' ).val( widgetNumber );
1762                         }
1763
1764                         widgetId = controlContainer.find( '[name="widget-id"]' ).val();
1765
1766                         controlContainer.hide(); // to be slid-down below
1767
1768                         settingId = 'widget_' + widget.get( 'id_base' );
1769                         if ( widget.get( 'is_multi' ) ) {
1770                                 settingId += '[' + widgetNumber + ']';
1771                         }
1772                         controlContainer.attr( 'id', 'customize-control-' + settingId.replace( /\]/g, '' ).replace( /\[/g, '-' ) );
1773
1774                         // Only create setting if it doesn't already exist (if we're adding a pre-existing inactive widget)
1775                         isExistingWidget = api.has( settingId );
1776                         if ( ! isExistingWidget ) {
1777                                 settingArgs = {
1778                                         transport: 'refresh',
1779                                         previewer: this.setting.previewer
1780                                 };
1781                                 setting = api.create( settingId, settingId, '', settingArgs );
1782                                 setting.set( {} ); // mark dirty, changing from '' to {}
1783                         }
1784
1785                         controlConstructor = api.controlConstructor[controlType];
1786                         widgetFormControl = new controlConstructor( settingId, {
1787                                 params: {
1788                                         settings: {
1789                                                 'default': settingId
1790                                         },
1791                                         content: controlContainer,
1792                                         sidebar_id: self.params.sidebar_id,
1793                                         widget_id: widgetId,
1794                                         widget_id_base: widget.get( 'id_base' ),
1795                                         type: controlType,
1796                                         is_new: ! isExistingWidget,
1797                                         width: widget.get( 'width' ),
1798                                         height: widget.get( 'height' ),
1799                                         is_wide: widget.get( 'is_wide' )
1800                                 },
1801                                 previewer: self.setting.previewer
1802                         } );
1803                         api.control.add( settingId, widgetFormControl );
1804
1805                         // Make sure widget is removed from the other sidebars
1806                         api.each( function( otherSetting ) {
1807                                 if ( otherSetting.id === self.setting.id ) {
1808                                         return;
1809                                 }
1810
1811                                 if ( 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) ) {
1812                                         return;
1813                                 }
1814
1815                                 var otherSidebarWidgets = otherSetting().slice(),
1816                                         i = _.indexOf( otherSidebarWidgets, widgetId );
1817
1818                                 if ( -1 !== i ) {
1819                                         otherSidebarWidgets.splice( i );
1820                                         otherSetting( otherSidebarWidgets );
1821                                 }
1822                         } );
1823
1824                         // Add widget to this sidebar
1825                         sidebarWidgets = this.setting().slice();
1826                         if ( -1 === _.indexOf( sidebarWidgets, widgetId ) ) {
1827                                 sidebarWidgets.push( widgetId );
1828                                 this.setting( sidebarWidgets );
1829                         }
1830
1831                         controlContainer.slideDown( function() {
1832                                 if ( isExistingWidget ) {
1833                                         widgetFormControl.expand();
1834                                         widgetFormControl.updateWidget( {
1835                                                 instance: widgetFormControl.setting(),
1836                                                 complete: function( error ) {
1837                                                         if ( error ) {
1838                                                                 throw error;
1839                                                         }
1840                                                         widgetFormControl.focus();
1841                                                 }
1842                                         } );
1843                                 } else {
1844                                         widgetFormControl.focus();
1845                                 }
1846                         } );
1847
1848                         return widgetFormControl;
1849                 }
1850         } );
1851
1852         // Register models for custom section and control types
1853         $.extend( api.sectionConstructor, {
1854                 sidebar: api.Widgets.SidebarSection
1855         });
1856         $.extend( api.controlConstructor, {
1857                 widget_form: api.Widgets.WidgetControl,
1858                 sidebar_widgets: api.Widgets.SidebarControl
1859         });
1860
1861         /**
1862          * Init Customizer for widgets.
1863          */
1864         api.bind( 'ready', function() {
1865                 // Set up the widgets panel
1866                 api.Widgets.availableWidgetsPanel = new api.Widgets.AvailableWidgetsPanelView({
1867                         collection: api.Widgets.availableWidgets
1868                 });
1869
1870                 // Highlight widget control
1871                 api.previewer.bind( 'highlight-widget-control', api.Widgets.highlightWidgetFormControl );
1872
1873                 // Open and focus widget control
1874                 api.previewer.bind( 'focus-widget-control', api.Widgets.focusWidgetFormControl );
1875         } );
1876
1877         /**
1878          * Highlight a widget control.
1879          *
1880          * @param {string} widgetId
1881          */
1882         api.Widgets.highlightWidgetFormControl = function( widgetId ) {
1883                 var control = api.Widgets.getWidgetFormControlForWidget( widgetId );
1884
1885                 if ( control ) {
1886                         control.highlightSectionAndControl();
1887                 }
1888         },
1889
1890         /**
1891          * Focus a widget control.
1892          *
1893          * @param {string} widgetId
1894          */
1895         api.Widgets.focusWidgetFormControl = function( widgetId ) {
1896                 var control = api.Widgets.getWidgetFormControlForWidget( widgetId );
1897
1898                 if ( control ) {
1899                         control.focus();
1900                 }
1901         },
1902
1903         /**
1904          * Given a widget control, find the sidebar widgets control that contains it.
1905          * @param {string} widgetId
1906          * @return {object|null}
1907          */
1908         api.Widgets.getSidebarWidgetControlContainingWidget = function( widgetId ) {
1909                 var foundControl = null;
1910
1911                 // @todo this can use widgetIdToSettingId(), then pass into wp.customize.control( x ).getSidebarWidgetsControl()
1912                 api.control.each( function( control ) {
1913                         if ( control.params.type === 'sidebar_widgets' && -1 !== _.indexOf( control.setting(), widgetId ) ) {
1914                                 foundControl = control;
1915                         }
1916                 } );
1917
1918                 return foundControl;
1919         };
1920
1921         /**
1922          * Given a widget ID for a widget appearing in the preview, get the widget form control associated with it.
1923          *
1924          * @param {string} widgetId
1925          * @return {object|null}
1926          */
1927         api.Widgets.getWidgetFormControlForWidget = function( widgetId ) {
1928                 var foundControl = null;
1929
1930                 // @todo We can just use widgetIdToSettingId() here
1931                 api.control.each( function( control ) {
1932                         if ( control.params.type === 'widget_form' && control.params.widget_id === widgetId ) {
1933                                 foundControl = control;
1934                         }
1935                 } );
1936
1937                 return foundControl;
1938         };
1939
1940         /**
1941          * @param {String} widgetId
1942          * @returns {Object}
1943          */
1944         function parseWidgetId( widgetId ) {
1945                 var matches, parsed = {
1946                         number: null,
1947                         id_base: null
1948                 };
1949
1950                 matches = widgetId.match( /^(.+)-(\d+)$/ );
1951                 if ( matches ) {
1952                         parsed.id_base = matches[1];
1953                         parsed.number = parseInt( matches[2], 10 );
1954                 } else {
1955                         // likely an old single widget
1956                         parsed.id_base = widgetId;
1957                 }
1958
1959                 return parsed;
1960         }
1961
1962         /**
1963          * @param {String} widgetId
1964          * @returns {String} settingId
1965          */
1966         function widgetIdToSettingId( widgetId ) {
1967                 var parsed = parseWidgetId( widgetId ), settingId;
1968
1969                 settingId = 'widget_' + parsed.id_base;
1970                 if ( parsed.number ) {
1971                         settingId += '[' + parsed.number + ']';
1972                 }
1973
1974                 return settingId;
1975         }
1976
1977 })( window.wp, jQuery );