/wp-admin/js/customize-widgets.js

https://gitlab.com/em645jn/brochure · JavaScript · 1694 lines · 1026 code · 265 blank · 403 comment · 182 complexity · abb70fb1b0901491d706d3734c5586aa MD5 · raw file

  1. /* global _wpCustomizeWidgetsSettings */
  2. (function( wp, $ ){
  3. if ( ! wp || ! wp.customize ) { return; }
  4. // Set up our namespace...
  5. var api = wp.customize,
  6. l10n;
  7. api.Widgets = api.Widgets || {};
  8. api.Widgets.savedWidgetIds = {};
  9. // Link settings
  10. api.Widgets.data = _wpCustomizeWidgetsSettings || {};
  11. l10n = api.Widgets.data.l10n;
  12. delete api.Widgets.data.l10n;
  13. /**
  14. * wp.customize.Widgets.WidgetModel
  15. *
  16. * A single widget model.
  17. *
  18. * @constructor
  19. * @augments Backbone.Model
  20. */
  21. api.Widgets.WidgetModel = Backbone.Model.extend({
  22. id: null,
  23. temp_id: null,
  24. classname: null,
  25. control_tpl: null,
  26. description: null,
  27. is_disabled: null,
  28. is_multi: null,
  29. multi_number: null,
  30. name: null,
  31. id_base: null,
  32. transport: null,
  33. params: [],
  34. width: null,
  35. height: null,
  36. search_matched: true
  37. });
  38. /**
  39. * wp.customize.Widgets.WidgetCollection
  40. *
  41. * Collection for widget models.
  42. *
  43. * @constructor
  44. * @augments Backbone.Model
  45. */
  46. api.Widgets.WidgetCollection = Backbone.Collection.extend({
  47. model: api.Widgets.WidgetModel,
  48. // Controls searching on the current widget collection
  49. // and triggers an update event
  50. doSearch: function( value ) {
  51. // Don't do anything if we've already done this search
  52. // Useful because the search handler fires multiple times per keystroke
  53. if ( this.terms === value ) {
  54. return;
  55. }
  56. // Updates terms with the value passed
  57. this.terms = value;
  58. // If we have terms, run a search...
  59. if ( this.terms.length > 0 ) {
  60. this.search( this.terms );
  61. }
  62. // If search is blank, show all themes
  63. // Useful for resetting the views when you clean the input
  64. if ( this.terms === '' ) {
  65. this.each( function ( widget ) {
  66. widget.set( 'search_matched', true );
  67. } );
  68. }
  69. },
  70. // Performs a search within the collection
  71. // @uses RegExp
  72. search: function( term ) {
  73. var match, haystack;
  74. // Escape the term string for RegExp meta characters
  75. term = term.replace( /[-\/\\^$*+?.()|[\]{}]/g, '\\$&' );
  76. // Consider spaces as word delimiters and match the whole string
  77. // so matching terms can be combined
  78. term = term.replace( / /g, ')(?=.*' );
  79. match = new RegExp( '^(?=.*' + term + ').+', 'i' );
  80. this.each( function ( data ) {
  81. haystack = [ data.get( 'name' ), data.get( 'id' ), data.get( 'description' ) ].join( ' ' );
  82. data.set( 'search_matched', match.test( haystack ) );
  83. } );
  84. }
  85. });
  86. api.Widgets.availableWidgets = new api.Widgets.WidgetCollection( api.Widgets.data.availableWidgets );
  87. /**
  88. * wp.customize.Widgets.SidebarModel
  89. *
  90. * A single sidebar model.
  91. *
  92. * @constructor
  93. * @augments Backbone.Model
  94. */
  95. api.Widgets.SidebarModel = Backbone.Model.extend({
  96. after_title: null,
  97. after_widget: null,
  98. before_title: null,
  99. before_widget: null,
  100. 'class': null,
  101. description: null,
  102. id: null,
  103. name: null,
  104. is_rendered: false
  105. });
  106. /**
  107. * wp.customize.Widgets.SidebarCollection
  108. *
  109. * Collection for sidebar models.
  110. *
  111. * @constructor
  112. * @augments Backbone.Collection
  113. */
  114. api.Widgets.SidebarCollection = Backbone.Collection.extend({
  115. model: api.Widgets.SidebarModel
  116. });
  117. api.Widgets.registeredSidebars = new api.Widgets.SidebarCollection( api.Widgets.data.registeredSidebars );
  118. /**
  119. * wp.customize.Widgets.AvailableWidgetsPanelView
  120. *
  121. * View class for the available widgets panel.
  122. *
  123. * @constructor
  124. * @augments wp.Backbone.View
  125. * @augments Backbone.View
  126. */
  127. api.Widgets.AvailableWidgetsPanelView = wp.Backbone.View.extend({
  128. el: '#available-widgets',
  129. events: {
  130. 'input #widgets-search': 'search',
  131. 'keyup #widgets-search': 'search',
  132. 'change #widgets-search': 'search',
  133. 'search #widgets-search': 'search',
  134. 'focus .widget-tpl' : 'focus',
  135. 'click .widget-tpl' : '_submit',
  136. 'keypress .widget-tpl' : '_submit',
  137. 'keydown' : 'keyboardAccessible'
  138. },
  139. // Cache current selected widget
  140. selected: null,
  141. // Cache sidebar control which has opened panel
  142. currentSidebarControl: null,
  143. $search: null,
  144. initialize: function() {
  145. var self = this;
  146. this.$search = $( '#widgets-search' );
  147. _.bindAll( this, 'close' );
  148. this.listenTo( this.collection, 'change', this.updateList );
  149. this.updateList();
  150. // If the available widgets panel is open and the customize controls are
  151. // interacted with (i.e. available widgets panel is blurred) then close the
  152. // available widgets panel. Also close on back button click.
  153. $( '#customize-controls, #available-widgets .customize-section-title' ).on( 'click keydown', function( e ) {
  154. var isAddNewBtn = $( e.target ).is( '.add-new-widget, .add-new-widget *' );
  155. if ( $( 'body' ).hasClass( 'adding-widget' ) && ! isAddNewBtn ) {
  156. self.close();
  157. }
  158. } );
  159. // Close the panel if the URL in the preview changes
  160. api.previewer.bind( 'url', this.close );
  161. },
  162. // Performs a search and handles selected widget
  163. search: function( event ) {
  164. var firstVisible;
  165. this.collection.doSearch( event.target.value );
  166. // Remove a widget from being selected if it is no longer visible
  167. if ( this.selected && ! this.selected.is( ':visible' ) ) {
  168. this.selected.removeClass( 'selected' );
  169. this.selected = null;
  170. }
  171. // If a widget was selected but the filter value has been cleared out, clear selection
  172. if ( this.selected && ! event.target.value ) {
  173. this.selected.removeClass( 'selected' );
  174. this.selected = null;
  175. }
  176. // If a filter has been entered and a widget hasn't been selected, select the first one shown
  177. if ( ! this.selected && event.target.value ) {
  178. firstVisible = this.$el.find( '> .widget-tpl:visible:first' );
  179. if ( firstVisible.length ) {
  180. this.select( firstVisible );
  181. }
  182. }
  183. },
  184. // Changes visibility of available widgets
  185. updateList: function() {
  186. this.collection.each( function( widget ) {
  187. var widgetTpl = $( '#widget-tpl-' + widget.id );
  188. widgetTpl.toggle( widget.get( 'search_matched' ) && ! widget.get( 'is_disabled' ) );
  189. if ( widget.get( 'is_disabled' ) && widgetTpl.is( this.selected ) ) {
  190. this.selected = null;
  191. }
  192. } );
  193. },
  194. // Highlights a widget
  195. select: function( widgetTpl ) {
  196. this.selected = $( widgetTpl );
  197. this.selected.siblings( '.widget-tpl' ).removeClass( 'selected' );
  198. this.selected.addClass( 'selected' );
  199. },
  200. // Highlights a widget on focus
  201. focus: function( event ) {
  202. this.select( $( event.currentTarget ) );
  203. },
  204. // Submit handler for keypress and click on widget
  205. _submit: function( event ) {
  206. // Only proceed with keypress if it is Enter or Spacebar
  207. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  208. return;
  209. }
  210. this.submit( $( event.currentTarget ) );
  211. },
  212. // Adds a selected widget to the sidebar
  213. submit: function( widgetTpl ) {
  214. var widgetId, widget, widgetFormControl;
  215. if ( ! widgetTpl ) {
  216. widgetTpl = this.selected;
  217. }
  218. if ( ! widgetTpl || ! this.currentSidebarControl ) {
  219. return;
  220. }
  221. this.select( widgetTpl );
  222. widgetId = $( this.selected ).data( 'widget-id' );
  223. widget = this.collection.findWhere( { id: widgetId } );
  224. if ( ! widget ) {
  225. return;
  226. }
  227. widgetFormControl = this.currentSidebarControl.addWidget( widget.get( 'id_base' ) );
  228. if ( widgetFormControl ) {
  229. widgetFormControl.focus();
  230. }
  231. this.close();
  232. },
  233. // Opens the panel
  234. open: function( sidebarControl ) {
  235. this.currentSidebarControl = sidebarControl;
  236. // Wide widget controls appear over the preview, and so they need to be collapsed when the panel opens
  237. _( this.currentSidebarControl.getWidgetFormControls() ).each( function( control ) {
  238. if ( control.params.is_wide ) {
  239. control.collapseForm();
  240. }
  241. } );
  242. $( 'body' ).addClass( 'adding-widget' );
  243. this.$el.find( '.selected' ).removeClass( 'selected' );
  244. // Reset search
  245. this.collection.doSearch( '' );
  246. if ( ! api.settings.browser.mobile ) {
  247. this.$search.focus();
  248. }
  249. },
  250. // Closes the panel
  251. close: function( options ) {
  252. options = options || {};
  253. if ( options.returnFocus && this.currentSidebarControl ) {
  254. this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
  255. }
  256. this.currentSidebarControl = null;
  257. this.selected = null;
  258. $( 'body' ).removeClass( 'adding-widget' );
  259. this.$search.val( '' );
  260. },
  261. // Add keyboard accessiblity to the panel
  262. keyboardAccessible: function( event ) {
  263. var isEnter = ( event.which === 13 ),
  264. isEsc = ( event.which === 27 ),
  265. isDown = ( event.which === 40 ),
  266. isUp = ( event.which === 38 ),
  267. isTab = ( event.which === 9 ),
  268. isShift = ( event.shiftKey ),
  269. selected = null,
  270. firstVisible = this.$el.find( '> .widget-tpl:visible:first' ),
  271. lastVisible = this.$el.find( '> .widget-tpl:visible:last' ),
  272. isSearchFocused = $( event.target ).is( this.$search ),
  273. isLastWidgetFocused = $( event.target ).is( '.widget-tpl:visible:last' );
  274. if ( isDown || isUp ) {
  275. if ( isDown ) {
  276. if ( isSearchFocused ) {
  277. selected = firstVisible;
  278. } else if ( this.selected && this.selected.nextAll( '.widget-tpl:visible' ).length !== 0 ) {
  279. selected = this.selected.nextAll( '.widget-tpl:visible:first' );
  280. }
  281. } else if ( isUp ) {
  282. if ( isSearchFocused ) {
  283. selected = lastVisible;
  284. } else if ( this.selected && this.selected.prevAll( '.widget-tpl:visible' ).length !== 0 ) {
  285. selected = this.selected.prevAll( '.widget-tpl:visible:first' );
  286. }
  287. }
  288. this.select( selected );
  289. if ( selected ) {
  290. selected.focus();
  291. } else {
  292. this.$search.focus();
  293. }
  294. return;
  295. }
  296. // If enter pressed but nothing entered, don't do anything
  297. if ( isEnter && ! this.$search.val() ) {
  298. return;
  299. }
  300. if ( isEnter ) {
  301. this.submit();
  302. } else if ( isEsc ) {
  303. this.close( { returnFocus: true } );
  304. }
  305. if ( this.currentSidebarControl && isTab && ( isShift && isSearchFocused || ! isShift && isLastWidgetFocused ) ) {
  306. this.currentSidebarControl.container.find( '.add-new-widget' ).focus();
  307. event.preventDefault();
  308. }
  309. }
  310. });
  311. /**
  312. * Handlers for the widget-synced event, organized by widget ID base.
  313. * Other widgets may provide their own update handlers by adding
  314. * listeners for the widget-synced event.
  315. */
  316. api.Widgets.formSyncHandlers = {
  317. /**
  318. * @param {jQuery.Event} e
  319. * @param {jQuery} widget
  320. * @param {String} newForm
  321. */
  322. rss: function( e, widget, newForm ) {
  323. var oldWidgetError = widget.find( '.widget-error:first' ),
  324. newWidgetError = $( '<div>' + newForm + '</div>' ).find( '.widget-error:first' );
  325. if ( oldWidgetError.length && newWidgetError.length ) {
  326. oldWidgetError.replaceWith( newWidgetError );
  327. } else if ( oldWidgetError.length ) {
  328. oldWidgetError.remove();
  329. } else if ( newWidgetError.length ) {
  330. widget.find( '.widget-content:first' ).prepend( newWidgetError );
  331. }
  332. }
  333. };
  334. /**
  335. * wp.customize.Widgets.WidgetControl
  336. *
  337. * Customizer control for widgets.
  338. * Note that 'widget_form' must match the WP_Widget_Form_Customize_Control::$type
  339. *
  340. * @constructor
  341. * @augments wp.customize.Control
  342. */
  343. api.Widgets.WidgetControl = api.Control.extend({
  344. defaultExpandedArguments: {
  345. duration: 'fast',
  346. completeCallback: $.noop
  347. },
  348. /**
  349. * @since 4.1.0
  350. */
  351. initialize: function( id, options ) {
  352. var control = this;
  353. control.widgetControlEmbedded = false;
  354. control.widgetContentEmbedded = false;
  355. control.expanded = new api.Value( false );
  356. control.expandedArgumentsQueue = [];
  357. control.expanded.bind( function( expanded ) {
  358. var args = control.expandedArgumentsQueue.shift();
  359. args = $.extend( {}, control.defaultExpandedArguments, args );
  360. control.onChangeExpanded( expanded, args );
  361. });
  362. control.altNotice = true;
  363. api.Control.prototype.initialize.call( control, id, options );
  364. },
  365. /**
  366. * Set up the control.
  367. *
  368. * @since 3.9.0
  369. */
  370. ready: function() {
  371. var control = this;
  372. /*
  373. * Embed a placeholder once the section is expanded. The full widget
  374. * form content will be embedded once the control itself is expanded,
  375. * and at this point the widget-added event will be triggered.
  376. */
  377. if ( ! control.section() ) {
  378. control.embedWidgetControl();
  379. } else {
  380. api.section( control.section(), function( section ) {
  381. var onExpanded = function( isExpanded ) {
  382. if ( isExpanded ) {
  383. control.embedWidgetControl();
  384. section.expanded.unbind( onExpanded );
  385. }
  386. };
  387. if ( section.expanded() ) {
  388. onExpanded( true );
  389. } else {
  390. section.expanded.bind( onExpanded );
  391. }
  392. } );
  393. }
  394. },
  395. /**
  396. * Embed the .widget element inside the li container.
  397. *
  398. * @since 4.4.0
  399. */
  400. embedWidgetControl: function() {
  401. var control = this, widgetControl;
  402. if ( control.widgetControlEmbedded ) {
  403. return;
  404. }
  405. control.widgetControlEmbedded = true;
  406. widgetControl = $( control.params.widget_control );
  407. control.container.append( widgetControl );
  408. control._setupModel();
  409. control._setupWideWidget();
  410. control._setupControlToggle();
  411. control._setupWidgetTitle();
  412. control._setupReorderUI();
  413. control._setupHighlightEffects();
  414. control._setupUpdateUI();
  415. control._setupRemoveUI();
  416. },
  417. /**
  418. * Embed the actual widget form inside of .widget-content and finally trigger the widget-added event.
  419. *
  420. * @since 4.4.0
  421. */
  422. embedWidgetContent: function() {
  423. var control = this, widgetContent;
  424. control.embedWidgetControl();
  425. if ( control.widgetContentEmbedded ) {
  426. return;
  427. }
  428. control.widgetContentEmbedded = true;
  429. widgetContent = $( control.params.widget_content );
  430. control.container.find( '.widget-content:first' ).append( widgetContent );
  431. /*
  432. * Trigger widget-added event so that plugins can attach any event
  433. * listeners and dynamic UI elements.
  434. */
  435. $( document ).trigger( 'widget-added', [ control.container.find( '.widget:first' ) ] );
  436. },
  437. /**
  438. * Handle changes to the setting
  439. */
  440. _setupModel: function() {
  441. var self = this, rememberSavedWidgetId;
  442. // Remember saved widgets so we know which to trash (move to inactive widgets sidebar)
  443. rememberSavedWidgetId = function() {
  444. api.Widgets.savedWidgetIds[self.params.widget_id] = true;
  445. };
  446. api.bind( 'ready', rememberSavedWidgetId );
  447. api.bind( 'saved', rememberSavedWidgetId );
  448. this._updateCount = 0;
  449. this.isWidgetUpdating = false;
  450. this.liveUpdateMode = true;
  451. // Update widget whenever model changes
  452. this.setting.bind( function( to, from ) {
  453. if ( ! _( from ).isEqual( to ) && ! self.isWidgetUpdating ) {
  454. self.updateWidget( { instance: to } );
  455. }
  456. } );
  457. },
  458. /**
  459. * Add special behaviors for wide widget controls
  460. */
  461. _setupWideWidget: function() {
  462. var self = this, $widgetInside, $widgetForm, $customizeSidebar,
  463. $themeControlsContainer, positionWidget;
  464. if ( ! this.params.is_wide ) {
  465. return;
  466. }
  467. $widgetInside = this.container.find( '.widget-inside' );
  468. $widgetForm = $widgetInside.find( '> .form' );
  469. $customizeSidebar = $( '.wp-full-overlay-sidebar-content:first' );
  470. this.container.addClass( 'wide-widget-control' );
  471. this.container.find( '.widget-content:first' ).css( {
  472. 'max-width': this.params.width,
  473. 'min-height': this.params.height
  474. } );
  475. /**
  476. * Keep the widget-inside positioned so the top of fixed-positioned
  477. * element is at the same top position as the widget-top. When the
  478. * widget-top is scrolled out of view, keep the widget-top in view;
  479. * likewise, don't allow the widget to drop off the bottom of the window.
  480. * If a widget is too tall to fit in the window, don't let the height
  481. * exceed the window height so that the contents of the widget control
  482. * will become scrollable (overflow:auto).
  483. */
  484. positionWidget = function() {
  485. var offsetTop = self.container.offset().top,
  486. windowHeight = $( window ).height(),
  487. formHeight = $widgetForm.outerHeight(),
  488. top;
  489. $widgetInside.css( 'max-height', windowHeight );
  490. top = Math.max(
  491. 0, // prevent top from going off screen
  492. Math.min(
  493. Math.max( offsetTop, 0 ), // distance widget in panel is from top of screen
  494. windowHeight - formHeight // flush up against bottom of screen
  495. )
  496. );
  497. $widgetInside.css( 'top', top );
  498. };
  499. $themeControlsContainer = $( '#customize-theme-controls' );
  500. this.container.on( 'expand', function() {
  501. positionWidget();
  502. $customizeSidebar.on( 'scroll', positionWidget );
  503. $( window ).on( 'resize', positionWidget );
  504. $themeControlsContainer.on( 'expanded collapsed', positionWidget );
  505. } );
  506. this.container.on( 'collapsed', function() {
  507. $customizeSidebar.off( 'scroll', positionWidget );
  508. $( window ).off( 'resize', positionWidget );
  509. $themeControlsContainer.off( 'expanded collapsed', positionWidget );
  510. } );
  511. // Reposition whenever a sidebar's widgets are changed
  512. api.each( function( setting ) {
  513. if ( 0 === setting.id.indexOf( 'sidebars_widgets[' ) ) {
  514. setting.bind( function() {
  515. if ( self.container.hasClass( 'expanded' ) ) {
  516. positionWidget();
  517. }
  518. } );
  519. }
  520. } );
  521. },
  522. /**
  523. * Show/hide the control when clicking on the form title, when clicking
  524. * the close button
  525. */
  526. _setupControlToggle: function() {
  527. var self = this, $closeBtn;
  528. this.container.find( '.widget-top' ).on( 'click', function( e ) {
  529. e.preventDefault();
  530. var sidebarWidgetsControl = self.getSidebarWidgetsControl();
  531. if ( sidebarWidgetsControl.isReordering ) {
  532. return;
  533. }
  534. self.expanded( ! self.expanded() );
  535. } );
  536. $closeBtn = this.container.find( '.widget-control-close' );
  537. $closeBtn.on( 'click', function( e ) {
  538. e.preventDefault();
  539. self.collapse();
  540. self.container.find( '.widget-top .widget-action:first' ).focus(); // keyboard accessibility
  541. } );
  542. },
  543. /**
  544. * Update the title of the form if a title field is entered
  545. */
  546. _setupWidgetTitle: function() {
  547. var self = this, updateTitle;
  548. updateTitle = function() {
  549. var title = self.setting().title,
  550. inWidgetTitle = self.container.find( '.in-widget-title' );
  551. if ( title ) {
  552. inWidgetTitle.text( ': ' + title );
  553. } else {
  554. inWidgetTitle.text( '' );
  555. }
  556. };
  557. this.setting.bind( updateTitle );
  558. updateTitle();
  559. },
  560. /**
  561. * Set up the widget-reorder-nav
  562. */
  563. _setupReorderUI: function() {
  564. var self = this, selectSidebarItem, $moveWidgetArea,
  565. $reorderNav, updateAvailableSidebars, template;
  566. /**
  567. * select the provided sidebar list item in the move widget area
  568. *
  569. * @param {jQuery} li
  570. */
  571. selectSidebarItem = function( li ) {
  572. li.siblings( '.selected' ).removeClass( 'selected' );
  573. li.addClass( 'selected' );
  574. var isSelfSidebar = ( li.data( 'id' ) === self.params.sidebar_id );
  575. self.container.find( '.move-widget-btn' ).prop( 'disabled', isSelfSidebar );
  576. };
  577. /**
  578. * Add the widget reordering elements to the widget control
  579. */
  580. this.container.find( '.widget-title-action' ).after( $( api.Widgets.data.tpl.widgetReorderNav ) );
  581. template = _.template( api.Widgets.data.tpl.moveWidgetArea );
  582. $moveWidgetArea = $( template( {
  583. sidebars: _( api.Widgets.registeredSidebars.toArray() ).pluck( 'attributes' )
  584. } )
  585. );
  586. this.container.find( '.widget-top' ).after( $moveWidgetArea );
  587. /**
  588. * Update available sidebars when their rendered state changes
  589. */
  590. updateAvailableSidebars = function() {
  591. var $sidebarItems = $moveWidgetArea.find( 'li' ), selfSidebarItem,
  592. renderedSidebarCount = 0;
  593. selfSidebarItem = $sidebarItems.filter( function(){
  594. return $( this ).data( 'id' ) === self.params.sidebar_id;
  595. } );
  596. $sidebarItems.each( function() {
  597. var li = $( this ),
  598. sidebarId, sidebar, sidebarIsRendered;
  599. sidebarId = li.data( 'id' );
  600. sidebar = api.Widgets.registeredSidebars.get( sidebarId );
  601. sidebarIsRendered = sidebar.get( 'is_rendered' );
  602. li.toggle( sidebarIsRendered );
  603. if ( sidebarIsRendered ) {
  604. renderedSidebarCount += 1;
  605. }
  606. if ( li.hasClass( 'selected' ) && ! sidebarIsRendered ) {
  607. selectSidebarItem( selfSidebarItem );
  608. }
  609. } );
  610. if ( renderedSidebarCount > 1 ) {
  611. self.container.find( '.move-widget' ).show();
  612. } else {
  613. self.container.find( '.move-widget' ).hide();
  614. }
  615. };
  616. updateAvailableSidebars();
  617. api.Widgets.registeredSidebars.on( 'change:is_rendered', updateAvailableSidebars );
  618. /**
  619. * Handle clicks for up/down/move on the reorder nav
  620. */
  621. $reorderNav = this.container.find( '.widget-reorder-nav' );
  622. $reorderNav.find( '.move-widget, .move-widget-down, .move-widget-up' ).each( function() {
  623. $( this ).prepend( self.container.find( '.widget-title' ).text() + ': ' );
  624. } ).on( 'click keypress', function( event ) {
  625. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  626. return;
  627. }
  628. $( this ).focus();
  629. if ( $( this ).is( '.move-widget' ) ) {
  630. self.toggleWidgetMoveArea();
  631. } else {
  632. var isMoveDown = $( this ).is( '.move-widget-down' ),
  633. isMoveUp = $( this ).is( '.move-widget-up' ),
  634. i = self.getWidgetSidebarPosition();
  635. if ( ( isMoveUp && i === 0 ) || ( isMoveDown && i === self.getSidebarWidgetsControl().setting().length - 1 ) ) {
  636. return;
  637. }
  638. if ( isMoveUp ) {
  639. self.moveUp();
  640. wp.a11y.speak( l10n.widgetMovedUp );
  641. } else {
  642. self.moveDown();
  643. wp.a11y.speak( l10n.widgetMovedDown );
  644. }
  645. $( this ).focus(); // re-focus after the container was moved
  646. }
  647. } );
  648. /**
  649. * Handle selecting a sidebar to move to
  650. */
  651. this.container.find( '.widget-area-select' ).on( 'click keypress', 'li', function( event ) {
  652. if ( event.type === 'keypress' && ( event.which !== 13 && event.which !== 32 ) ) {
  653. return;
  654. }
  655. event.preventDefault();
  656. selectSidebarItem( $( this ) );
  657. } );
  658. /**
  659. * Move widget to another sidebar
  660. */
  661. this.container.find( '.move-widget-btn' ).click( function() {
  662. self.getSidebarWidgetsControl().toggleReordering( false );
  663. var oldSidebarId = self.params.sidebar_id,
  664. newSidebarId = self.container.find( '.widget-area-select li.selected' ).data( 'id' ),
  665. oldSidebarWidgetsSetting, newSidebarWidgetsSetting,
  666. oldSidebarWidgetIds, newSidebarWidgetIds, i;
  667. oldSidebarWidgetsSetting = api( 'sidebars_widgets[' + oldSidebarId + ']' );
  668. newSidebarWidgetsSetting = api( 'sidebars_widgets[' + newSidebarId + ']' );
  669. oldSidebarWidgetIds = Array.prototype.slice.call( oldSidebarWidgetsSetting() );
  670. newSidebarWidgetIds = Array.prototype.slice.call( newSidebarWidgetsSetting() );
  671. i = self.getWidgetSidebarPosition();
  672. oldSidebarWidgetIds.splice( i, 1 );
  673. newSidebarWidgetIds.push( self.params.widget_id );
  674. oldSidebarWidgetsSetting( oldSidebarWidgetIds );
  675. newSidebarWidgetsSetting( newSidebarWidgetIds );
  676. self.focus();
  677. } );
  678. },
  679. /**
  680. * Highlight widgets in preview when interacted with in the Customizer
  681. */
  682. _setupHighlightEffects: function() {
  683. var self = this;
  684. // Highlight whenever hovering or clicking over the form
  685. this.container.on( 'mouseenter click', function() {
  686. self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
  687. } );
  688. // Highlight when the setting is updated
  689. this.setting.bind( function() {
  690. self.setting.previewer.send( 'highlight-widget', self.params.widget_id );
  691. } );
  692. },
  693. /**
  694. * Set up event handlers for widget updating
  695. */
  696. _setupUpdateUI: function() {
  697. var self = this, $widgetRoot, $widgetContent,
  698. $saveBtn, updateWidgetDebounced, formSyncHandler;
  699. $widgetRoot = this.container.find( '.widget:first' );
  700. $widgetContent = $widgetRoot.find( '.widget-content:first' );
  701. // Configure update button
  702. $saveBtn = this.container.find( '.widget-control-save' );
  703. $saveBtn.val( l10n.saveBtnLabel );
  704. $saveBtn.attr( 'title', l10n.saveBtnTooltip );
  705. $saveBtn.removeClass( 'button-primary' ).addClass( 'button-secondary' );
  706. $saveBtn.on( 'click', function( e ) {
  707. e.preventDefault();
  708. self.updateWidget( { disable_form: true } ); // @todo disable_form is unused?
  709. } );
  710. updateWidgetDebounced = _.debounce( function() {
  711. self.updateWidget();
  712. }, 250 );
  713. // Trigger widget form update when hitting Enter within an input
  714. $widgetContent.on( 'keydown', 'input', function( e ) {
  715. if ( 13 === e.which ) { // Enter
  716. e.preventDefault();
  717. self.updateWidget( { ignoreActiveElement: true } );
  718. }
  719. } );
  720. // Handle widgets that support live previews
  721. $widgetContent.on( 'change input propertychange', ':input', function( e ) {
  722. if ( ! self.liveUpdateMode ) {
  723. return;
  724. }
  725. if ( e.type === 'change' || ( this.checkValidity && this.checkValidity() ) ) {
  726. updateWidgetDebounced();
  727. }
  728. } );
  729. // Remove loading indicators when the setting is saved and the preview updates
  730. this.setting.previewer.channel.bind( 'synced', function() {
  731. self.container.removeClass( 'previewer-loading' );
  732. } );
  733. api.previewer.bind( 'widget-updated', function( updatedWidgetId ) {
  734. if ( updatedWidgetId === self.params.widget_id ) {
  735. self.container.removeClass( 'previewer-loading' );
  736. }
  737. } );
  738. formSyncHandler = api.Widgets.formSyncHandlers[ this.params.widget_id_base ];
  739. if ( formSyncHandler ) {
  740. $( document ).on( 'widget-synced', function( e, widget ) {
  741. if ( $widgetRoot.is( widget ) ) {
  742. formSyncHandler.apply( document, arguments );
  743. }
  744. } );
  745. }
  746. },
  747. /**
  748. * Update widget control to indicate whether it is currently rendered.
  749. *
  750. * Overrides api.Control.toggle()
  751. *
  752. * @since 4.1.0
  753. *
  754. * @param {Boolean} active
  755. * @param {Object} args
  756. * @param {Callback} args.completeCallback
  757. */
  758. onChangeActive: function ( active, args ) {
  759. // Note: there is a second 'args' parameter being passed, merged on top of this.defaultActiveArguments
  760. this.container.toggleClass( 'widget-rendered', active );
  761. if ( args.completeCallback ) {
  762. args.completeCallback();
  763. }
  764. },
  765. /**
  766. * Set up event handlers for widget removal
  767. */
  768. _setupRemoveUI: function() {
  769. var self = this, $removeBtn, replaceDeleteWithRemove;
  770. // Configure remove button
  771. $removeBtn = this.container.find( 'a.widget-control-remove' );
  772. $removeBtn.on( 'click', function( e ) {
  773. e.preventDefault();
  774. // Find an adjacent element to add focus to when this widget goes away
  775. var $adjacentFocusTarget;
  776. if ( self.container.next().is( '.customize-control-widget_form' ) ) {
  777. $adjacentFocusTarget = self.container.next().find( '.widget-action:first' );
  778. } else if ( self.container.prev().is( '.customize-control-widget_form' ) ) {
  779. $adjacentFocusTarget = self.container.prev().find( '.widget-action:first' );
  780. } else {
  781. $adjacentFocusTarget = self.container.next( '.customize-control-sidebar_widgets' ).find( '.add-new-widget:first' );
  782. }
  783. self.container.slideUp( function() {
  784. var sidebarsWidgetsControl = api.Widgets.getSidebarWidgetControlContainingWidget( self.params.widget_id ),
  785. sidebarWidgetIds, i;
  786. if ( ! sidebarsWidgetsControl ) {
  787. return;
  788. }
  789. sidebarWidgetIds = sidebarsWidgetsControl.setting().slice();
  790. i = _.indexOf( sidebarWidgetIds, self.params.widget_id );
  791. if ( -1 === i ) {
  792. return;
  793. }
  794. sidebarWidgetIds.splice( i, 1 );
  795. sidebarsWidgetsControl.setting( sidebarWidgetIds );
  796. $adjacentFocusTarget.focus(); // keyboard accessibility
  797. } );
  798. } );
  799. replaceDeleteWithRemove = function() {
  800. $removeBtn.text( l10n.removeBtnLabel ); // wp_widget_control() outputs the link as "Delete"
  801. $removeBtn.attr( 'title', l10n.removeBtnTooltip );
  802. };
  803. if ( this.params.is_new ) {
  804. api.bind( 'saved', replaceDeleteWithRemove );
  805. } else {
  806. replaceDeleteWithRemove();
  807. }
  808. },
  809. /**
  810. * Find all inputs in a widget container that should be considered when
  811. * comparing the loaded form with the sanitized form, whose fields will
  812. * be aligned to copy the sanitized over. The elements returned by this
  813. * are passed into this._getInputsSignature(), and they are iterated
  814. * over when copying sanitized values over to the form loaded.
  815. *
  816. * @param {jQuery} container element in which to look for inputs
  817. * @returns {jQuery} inputs
  818. * @private
  819. */
  820. _getInputs: function( container ) {
  821. return $( container ).find( ':input[name]' );
  822. },
  823. /**
  824. * Iterate over supplied inputs and create a signature string for all of them together.
  825. * This string can be used to compare whether or not the form has all of the same fields.
  826. *
  827. * @param {jQuery} inputs
  828. * @returns {string}
  829. * @private
  830. */
  831. _getInputsSignature: function( inputs ) {
  832. var inputsSignatures = _( inputs ).map( function( input ) {
  833. var $input = $( input ), signatureParts;
  834. if ( $input.is( ':checkbox, :radio' ) ) {
  835. signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ), $input.prop( 'value' ) ];
  836. } else {
  837. signatureParts = [ $input.attr( 'id' ), $input.attr( 'name' ) ];
  838. }
  839. return signatureParts.join( ',' );
  840. } );
  841. return inputsSignatures.join( ';' );
  842. },
  843. /**
  844. * Get the state for an input depending on its type.
  845. *
  846. * @param {jQuery|Element} input
  847. * @returns {string|boolean|array|*}
  848. * @private
  849. */
  850. _getInputState: function( input ) {
  851. input = $( input );
  852. if ( input.is( ':radio, :checkbox' ) ) {
  853. return input.prop( 'checked' );
  854. } else if ( input.is( 'select[multiple]' ) ) {
  855. return input.find( 'option:selected' ).map( function () {
  856. return $( this ).val();
  857. } ).get();
  858. } else {
  859. return input.val();
  860. }
  861. },
  862. /**
  863. * Update an input's state based on its type.
  864. *
  865. * @param {jQuery|Element} input
  866. * @param {string|boolean|array|*} state
  867. * @private
  868. */
  869. _setInputState: function ( input, state ) {
  870. input = $( input );
  871. if ( input.is( ':radio, :checkbox' ) ) {
  872. input.prop( 'checked', state );
  873. } else if ( input.is( 'select[multiple]' ) ) {
  874. if ( ! $.isArray( state ) ) {
  875. state = [];
  876. } else {
  877. // Make sure all state items are strings since the DOM value is a string
  878. state = _.map( state, function ( value ) {
  879. return String( value );
  880. } );
  881. }
  882. input.find( 'option' ).each( function () {
  883. $( this ).prop( 'selected', -1 !== _.indexOf( state, String( this.value ) ) );
  884. } );
  885. } else {
  886. input.val( state );
  887. }
  888. },
  889. /***********************************************************************
  890. * Begin public API methods
  891. **********************************************************************/
  892. /**
  893. * @return {wp.customize.controlConstructor.sidebar_widgets[]}
  894. */
  895. getSidebarWidgetsControl: function() {
  896. var settingId, sidebarWidgetsControl;
  897. settingId = 'sidebars_widgets[' + this.params.sidebar_id + ']';
  898. sidebarWidgetsControl = api.control( settingId );
  899. if ( ! sidebarWidgetsControl ) {
  900. return;
  901. }
  902. return sidebarWidgetsControl;
  903. },
  904. /**
  905. * Submit the widget form via Ajax and get back the updated instance,
  906. * along with the new widget control form to render.
  907. *
  908. * @param {object} [args]
  909. * @param {Object|null} [args.instance=null] When the model changes, the instance is sent here; otherwise, the inputs from the form are used
  910. * @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.
  911. * @param {Boolean} [args.ignoreActiveElement=false] Whether or not updating a field will be deferred if focus is still on the element.
  912. */
  913. updateWidget: function( args ) {
  914. var self = this, instanceOverride, completeCallback, $widgetRoot, $widgetContent,
  915. updateNumber, params, data, $inputs, processing, jqxhr, isChanged;
  916. // The updateWidget logic requires that the form fields to be fully present.
  917. self.embedWidgetContent();
  918. args = $.extend( {
  919. instance: null,
  920. complete: null,
  921. ignoreActiveElement: false
  922. }, args );
  923. instanceOverride = args.instance;
  924. completeCallback = args.complete;
  925. this._updateCount += 1;
  926. updateNumber = this._updateCount;
  927. $widgetRoot = this.container.find( '.widget:first' );
  928. $widgetContent = $widgetRoot.find( '.widget-content:first' );
  929. // Remove a previous error message
  930. $widgetContent.find( '.widget-error' ).remove();
  931. this.container.addClass( 'widget-form-loading' );
  932. this.container.addClass( 'previewer-loading' );
  933. processing = api.state( 'processing' );
  934. processing( processing() + 1 );
  935. if ( ! this.liveUpdateMode ) {
  936. this.container.addClass( 'widget-form-disabled' );
  937. }
  938. params = {};
  939. params.action = 'update-widget';
  940. params.wp_customize = 'on';
  941. params.nonce = api.settings.nonce['update-widget'];
  942. params.theme = api.settings.theme.stylesheet;
  943. params.customized = wp.customize.previewer.query().customized;
  944. data = $.param( params );
  945. $inputs = this._getInputs( $widgetContent );
  946. // Store the value we're submitting in data so that when the response comes back,
  947. // we know if it got sanitized; if there is no difference in the sanitized value,
  948. // then we do not need to touch the UI and mess up the user's ongoing editing.
  949. $inputs.each( function() {
  950. $( this ).data( 'state' + updateNumber, self._getInputState( this ) );
  951. } );
  952. if ( instanceOverride ) {
  953. data += '&' + $.param( { 'sanitized_widget_setting': JSON.stringify( instanceOverride ) } );
  954. } else {
  955. data += '&' + $inputs.serialize();
  956. }
  957. data += '&' + $widgetContent.find( '~ :input' ).serialize();
  958. if ( this._previousUpdateRequest ) {
  959. this._previousUpdateRequest.abort();
  960. }
  961. jqxhr = $.post( wp.ajax.settings.url, data );
  962. this._previousUpdateRequest = jqxhr;
  963. jqxhr.done( function( r ) {
  964. var message, sanitizedForm, $sanitizedInputs, hasSameInputsInResponse,
  965. isLiveUpdateAborted = false;
  966. // Check if the user is logged out.
  967. if ( '0' === r ) {
  968. api.previewer.preview.iframe.hide();
  969. api.previewer.login().done( function() {
  970. self.updateWidget( args );
  971. api.previewer.preview.iframe.show();
  972. } );
  973. return;
  974. }
  975. // Check for cheaters.
  976. if ( '-1' === r ) {
  977. api.previewer.cheatin();
  978. return;
  979. }
  980. if ( r.success ) {
  981. sanitizedForm = $( '<div>' + r.data.form + '</div>' );
  982. $sanitizedInputs = self._getInputs( sanitizedForm );
  983. hasSameInputsInResponse = self._getInputsSignature( $inputs ) === self._getInputsSignature( $sanitizedInputs );
  984. // Restore live update mode if sanitized fields are now aligned with the existing fields
  985. if ( hasSameInputsInResponse && ! self.liveUpdateMode ) {
  986. self.liveUpdateMode = true;
  987. self.container.removeClass( 'widget-form-disabled' );
  988. self.container.find( 'input[name="savewidget"]' ).hide();
  989. }
  990. // Sync sanitized field states to existing fields if they are aligned
  991. if ( hasSameInputsInResponse && self.liveUpdateMode ) {
  992. $inputs.each( function( i ) {
  993. var $input = $( this ),
  994. $sanitizedInput = $( $sanitizedInputs[i] ),
  995. submittedState, sanitizedState, canUpdateState;
  996. submittedState = $input.data( 'state' + updateNumber );
  997. sanitizedState = self._getInputState( $sanitizedInput );
  998. $input.data( 'sanitized', sanitizedState );
  999. canUpdateState = ( ! _.isEqual( submittedState, sanitizedState ) && ( args.ignoreActiveElement || ! $input.is( document.activeElement ) ) );
  1000. if ( canUpdateState ) {
  1001. self._setInputState( $input, sanitizedState );
  1002. }
  1003. } );
  1004. $( document ).trigger( 'widget-synced', [ $widgetRoot, r.data.form ] );
  1005. // Otherwise, if sanitized fields are not aligned with existing fields, disable live update mode if enabled
  1006. } else if ( self.liveUpdateMode ) {
  1007. self.liveUpdateMode = false;
  1008. self.container.find( 'input[name="savewidget"]' ).show();
  1009. isLiveUpdateAborted = true;
  1010. // Otherwise, replace existing form with the sanitized form
  1011. } else {
  1012. $widgetContent.html( r.data.form );
  1013. self.container.removeClass( 'widget-form-disabled' );
  1014. $( document ).trigger( 'widget-updated', [ $widgetRoot ] );
  1015. }
  1016. /**
  1017. * If the old instance is identical to the new one, there is nothing new
  1018. * needing to be rendered, and so we can preempt the event for the
  1019. * preview finishing loading.
  1020. */
  1021. isChanged = ! isLiveUpdateAborted && ! _( self.setting() ).isEqual( r.data.instance );
  1022. if ( isChanged ) {
  1023. self.isWidgetUpdating = true; // suppress triggering another updateWidget
  1024. self.setting( r.data.instance );
  1025. self.isWidgetUpdating = false;
  1026. } else {
  1027. // no change was made, so stop the spinner now instead of when the preview would updates
  1028. self.container.removeClass( 'previewer-loading' );
  1029. }
  1030. if ( completeCallback ) {
  1031. completeCallback.call( self, null, { noChange: ! isChanged, ajaxFinished: true } );
  1032. }
  1033. } else {
  1034. // General error message
  1035. message = l10n.error;
  1036. if ( r.data && r.data.message ) {
  1037. message = r.data.message;
  1038. }
  1039. if ( completeCallback ) {
  1040. completeCallback.call( self, message );
  1041. } else {
  1042. $widgetContent.prepend( '<p class="widget-error"><strong>' + message + '</strong></p>' );
  1043. }
  1044. }
  1045. } );
  1046. jqxhr.fail( function( jqXHR, textStatus ) {
  1047. if ( completeCallback ) {
  1048. completeCallback.call( self, textStatus );
  1049. }
  1050. } );
  1051. jqxhr.always( function() {
  1052. self.container.removeClass( 'widget-form-loading' );
  1053. $inputs.each( function() {
  1054. $( this ).removeData( 'state' + updateNumber );
  1055. } );
  1056. processing( processing() - 1 );
  1057. } );
  1058. },
  1059. /**
  1060. * Expand the accordion section containing a control
  1061. */
  1062. expandControlSection: function() {
  1063. api.Control.prototype.expand.call( this );
  1064. },
  1065. /**
  1066. * @since 4.1.0
  1067. *
  1068. * @param {Boolean} expanded
  1069. * @param {Object} [params]
  1070. * @returns {Boolean} false if state already applied
  1071. */
  1072. _toggleExpanded: api.Section.prototype._toggleExpanded,
  1073. /**
  1074. * @since 4.1.0
  1075. *
  1076. * @param {Object} [params]
  1077. * @returns {Boolean} false if already expanded
  1078. */
  1079. expand: api.Section.prototype.expand,
  1080. /**
  1081. * Expand the widget form control
  1082. *
  1083. * @deprecated 4.1.0 Use this.expand() instead.
  1084. */
  1085. expandForm: function() {
  1086. this.expand();
  1087. },
  1088. /**
  1089. * @since 4.1.0
  1090. *
  1091. * @param {Object} [params]
  1092. * @returns {Boolean} false if already collapsed
  1093. */
  1094. collapse: api.Section.prototype.collapse,
  1095. /**
  1096. * Collapse the widget form control
  1097. *
  1098. * @deprecated 4.1.0 Use this.collapse() instead.
  1099. */
  1100. collapseForm: function() {
  1101. this.collapse();
  1102. },
  1103. /**
  1104. * Expand or collapse the widget control
  1105. *
  1106. * @deprecated this is poor naming, and it is better to directly set control.expanded( showOrHide )
  1107. *
  1108. * @param {boolean|undefined} [showOrHide] If not supplied, will be inverse of current visibility
  1109. */
  1110. toggleForm: function( showOrHide ) {
  1111. if ( typeof showOrHide === 'undefined' ) {
  1112. showOrHide = ! this.expanded();
  1113. }
  1114. this.expanded( showOrHide );
  1115. },
  1116. /**
  1117. * Respond to change in the expanded state.
  1118. *
  1119. * @param {Boolean} expanded
  1120. * @param {Object} args merged on top of this.defaultActiveArguments
  1121. */
  1122. onChangeExpanded: function ( expanded, args ) {
  1123. var self = this, $widget, $inside, complete, prevComplete;
  1124. self.embedWidgetControl(); // Make sure the outer form is embedded so that the expanded state can be set in the UI.
  1125. if ( expanded ) {
  1126. self.embedWidgetContent();
  1127. }
  1128. // If the expanded state is unchanged only manipulate container expanded states
  1129. if ( args.unchanged ) {
  1130. if ( expanded ) {
  1131. api.Control.prototype.expand.call( self, {
  1132. completeCallback: args.completeCallback
  1133. });
  1134. }
  1135. return;
  1136. }
  1137. $widget = this.container.find( 'div.widget:first' );
  1138. $inside = $widget.find( '.widget-inside:first' );
  1139. if ( expanded ) {
  1140. if ( self.section() && api.section( self.section() ) ) {
  1141. self.expandControlSection();
  1142. }
  1143. // Close all other widget controls before expanding this one
  1144. api.control.each( function( otherControl ) {
  1145. if ( self.params.type === otherControl.params.type && self !== otherControl ) {
  1146. otherControl.collapse();
  1147. }
  1148. } );
  1149. complete = function() {
  1150. self.container.removeClass( 'expanding' );
  1151. self.container.addClass( 'expanded' );
  1152. self.container.trigger( 'expanded' );
  1153. };
  1154. if ( args.completeCallback ) {
  1155. prevComplete = complete;
  1156. complete = function () {
  1157. prevComplete();
  1158. args.completeCallback();
  1159. };
  1160. }
  1161. if ( self.params.is_wide ) {
  1162. $inside.fadeIn( args.duration, complete );
  1163. } else {
  1164. $inside.slideDown( args.duration, complete );
  1165. }
  1166. self.container.trigger( 'expand' );
  1167. self.container.addClass( 'expanding' );
  1168. } else {
  1169. complete = function() {
  1170. self.container.removeClass( 'collapsing' );
  1171. self.container.removeClass( 'expanded' );
  1172. self.container.trigger( 'collapsed' );
  1173. };
  1174. if ( args.completeCallback ) {
  1175. prevComplete = complete;
  1176. complete = function () {
  1177. prevComplete();
  1178. args.completeCallback();
  1179. };
  1180. }
  1181. self.container.trigger( 'collapse' );
  1182. self.container.addClass( 'collapsing' );
  1183. if ( self.params.is_wide ) {
  1184. $inside.fadeOut( args.duration, complete );
  1185. } else {
  1186. $inside.slideUp( args.duration, function() {
  1187. $widget.css( { width:'', margin:'' } );
  1188. complete();
  1189. } );
  1190. }
  1191. }
  1192. },
  1193. /**
  1194. * Get the position (index) of the widget in the containing sidebar
  1195. *
  1196. * @returns {Number}
  1197. */
  1198. getWidgetSidebarPosition: function() {
  1199. var sidebarWidgetIds, position;
  1200. sidebarWidgetIds = this.getSidebarWidgetsControl().setting();
  1201. position = _.indexOf( sidebarWidgetIds, this.params.widget_id );
  1202. if ( position === -1 ) {
  1203. return;
  1204. }
  1205. return position;
  1206. },
  1207. /**
  1208. * Move widget up one in the sidebar
  1209. */
  1210. moveUp: function() {
  1211. this._moveWidgetByOne( -1 );
  1212. },
  1213. /**
  1214. * Move widget up one in the sidebar
  1215. */
  1216. moveDown: function() {
  1217. this._moveWidgetByOne( 1 );
  1218. },
  1219. /**
  1220. * @private
  1221. *
  1222. * @param {Number} offset 1|-1
  1223. */
  1224. _moveWidgetByOne: function( offset ) {
  1225. var i, sidebarWidgetsSetting, sidebarWidgetIds, adjacentWidgetId;
  1226. i = this.getWidgetSidebarPosition();
  1227. sidebarWidgetsSetting = this.getSidebarWidgetsControl().setting;
  1228. sidebarWidgetIds = Array.prototype.slice.call( sidebarWidgetsSetting() ); // clone
  1229. adjacentWidgetId = sidebarWidgetIds[i + offset];
  1230. sidebarWidgetIds[i + offset] = this.params.widget_id;
  1231. sidebarWidgetIds[i] = adjacentWidgetId;
  1232. sidebarWidgetsSetting( sidebarWidgetIds );
  1233. },
  1234. /**
  1235. * Toggle visibility of the widget move area
  1236. *
  1237. * @param {Boolean} [showOrHide]
  1238. */
  1239. toggleWidgetMoveArea: function( showOrHide ) {
  1240. var self = this, $moveWidgetArea;
  1241. $moveWidgetArea = this.container.find( '.move-widget-area' );
  1242. if ( typeof showOrHide === 'undefined' ) {
  1243. showOrHide = ! $moveWidgetArea.hasClass( 'active' );
  1244. }
  1245. if ( showOrHide ) {
  1246. // reset the selected sidebar
  1247. $moveWidgetArea.find( '.selected' ).removeClass( 'selected' );
  1248. $moveWidgetArea.find( 'li' ).filter( function() {
  1249. return $( this ).data( 'id' ) === self.params.sidebar_id;
  1250. } ).addClass( 'selected' );
  1251. this.container.find( '.move-widget-btn' ).prop( 'disabled', true );
  1252. }
  1253. $moveWidgetArea.toggleClass( 'active', showOrHide );
  1254. },
  1255. /**
  1256. * Highlight the widget control and section
  1257. */
  1258. highlightSectionAndControl: function() {
  1259. var $target;
  1260. if ( this.container.is( ':hidden' ) ) {
  1261. $target = this.container.closest( '.control-section' );
  1262. } else {
  1263. $target = this.container;
  1264. }
  1265. $( '.highlighted' ).removeClass( 'highlighted' );
  1266. $target.addClass( 'highlighted' );
  1267. setTimeout( function() {
  1268. $target.removeClass( 'highlighted' );
  1269. }, 500 );
  1270. }
  1271. } );
  1272. /**
  1273. * wp.customize.Widgets.WidgetsPanel
  1274. *
  1275. * Customizer panel containing the widget area sections.
  1276. *
  1277. * @since 4.4.0
  1278. */
  1279. api.Widgets.WidgetsPanel = api.Panel.extend({
  1280. /**
  1281. * Add and manage the display of the no-rendered-areas notice.
  1282. *
  1283. * @since 4.4.0
  1284. */
  1285. ready: function () {
  1286. var panel = this;
  1287. api.Panel.prototype.ready.call( panel );
  1288. panel.deferred.embedded.done(function() {
  1289. var panelMetaContainer, noRenderedAreasNotice, shouldShowNotice;
  1290. panelMetaContainer = panel.container.find( '.panel-meta' );
  1291. noRenderedAreasNotice = $( '<div></div>', {
  1292. 'class': 'no-widget-areas-rendered-notice'
  1293. });
  1294. noRenderedAreasNotice.append( $( '<em></em>', {
  1295. text: l10n.noAreasRendered
  1296. } ) );
  1297. panelMetaContainer.append( noRenderedAreasNotice );
  1298. shouldShowNotice = function() {
  1299. return ( 0 === _.filter( panel.sections(), function( section ) {
  1300. return section.active();
  1301. } ).length );
  1302. };
  1303. /*
  1304. * Set the initial visibility state for rendered notice.
  1305. * Update the visibility of the notice whenever a reflow happens.
  1306. */
  1307. noRenderedAreasNotice.toggle( shouldShowNotice() );
  1308. api.previewer.deferred.active.done( function () {
  1309. noRenderedAreasNotice.toggle( shouldShowNotice() );
  1310. });
  1311. api.bind( 'pane-contents-reflowed', function() {
  1312. var duration = ( 'resolved' === api.previewer.deferred.active.state() ) ? 'fast' : 0;
  1313. if ( shouldShowNotice() ) {
  1314. noRenderedAreasNotice.slideDown( duration );
  1315. } else {
  1316. noRenderedAreasNotice.slideUp( duration );
  1317. }
  1318. });
  1319. });
  1320. },
  1321. /**
  1322. * Allow an active widgets panel to be contextually active even when it has no active sections (widget areas).
  1323. *
  1324. * This ensures that the widgets panel appears even when there are no
  1325. * sidebars displayed on the URL currently being previewed.
  1326. *
  1327. * @since 4.4.0
  1328. *
  1329. * @returns {boolean}
  1330. */
  1331. isContextuallyActive: function() {
  1332. var panel = this;
  1333. return panel.active();
  1334. }
  1335. });
  1336. /**
  1337. * wp.customize.Widgets.SidebarSection
  1338. *
  1339. * Customizer section representing a widget area widget
  1340. *
  1341. * @since 4.1.0
  1342. */
  1343. api.Widgets.SidebarSection = api.Section.extend({
  1344. /**
  1345. * Sync the section's active state back to the Backbone model's is_rendered attribute
  1346. *
  1347. * @since 4.1.0
  1348. */
  1349. ready: function () {
  1350. var section = this, registeredSidebar;
  1351. api.Section.prototype.ready.call( this );
  1352. registeredSidebar = api.Widgets.registeredSidebars.get( section.params.sidebarId );
  1353. section.active.bind( function ( active ) {
  1354. registeredSidebar.set( 'is_rendered', active );
  1355. });
  1356. registeredSidebar.set( 'is_rendered', section.active() );
  1357. }
  1358. });
  1359. /**
  1360. * wp.customize.Widgets.SidebarControl
  1361. *
  1362. * Customizer control for widgets.
  1363. * Note that 'sidebar_widgets' must match the WP_Widget_Area_Customize_Control::$type
  1364. *
  1365. * @since 3.9.0
  1366. *
  1367. * @constructor
  1368. * @augments wp.customize.Control
  1369. */
  1370. api.Widgets.SidebarControl = api.Control.extend({
  1371. /**
  1372. * Set up the control
  1373. */
  1374. ready: function() {
  1375. this.$controlSection = this.container.closest( '.control-section' );
  1376. this.$sectionContent = this.container.closest( '.accordion-section-content' );
  1377. this._setupModel();
  1378. this._setupSortable();
  1379. this._setupAddition();
  1380. this._applyCardinalOrderClassNames();
  1381. },
  1382. /**
  1383. * Update ordering of widget control forms when the setting is updated
  1384. */
  1385. _setupModel: function() {
  1386. var self = this;
  1387. this.setting.bind( function( newWidgetIds, oldWidgetIds ) {
  1388. var widgetFormControls, removedWidgetIds, priority;
  1389. removedWidgetIds = _( oldWidgetIds ).difference( newWidgetIds );
  1390. // Filter out any persistent widget IDs for widgets which have been deactivated
  1391. newWidgetIds = _( newWidgetIds ).filter( function( newWidgetId ) {
  1392. var parsedWidgetId = parseWidgetId( newWidgetId );
  1393. return !! api.Widgets.availableWidgets.findWhere( { id_base: parsedWidgetId.id_base } );
  1394. } );
  1395. widgetFormControls = _( newWidgetIds ).map( function( widgetId ) {
  1396. var widgetFormControl = api.Widgets.getWidgetFormControlForWidget( widgetId );
  1397. if ( ! widgetFormControl ) {
  1398. widgetFormControl = self.addWidget( widgetId );
  1399. }
  1400. return widgetFormControl;
  1401. } );
  1402. // Sort widget controls to their new positions
  1403. widgetFormControls.sort( function( a, b ) {
  1404. var aIndex = _.indexOf( newWidgetIds, a.params.widget_id ),
  1405. bIndex = _.indexOf( newWidgetIds, b.params.widget_id );
  1406. return aIndex - bIndex;
  1407. });
  1408. priority = 0;
  1409. _( widgetFormControls ).each( function ( control ) {
  1410. control.priority( priority );
  1411. control.section( self.section() );
  1412. priority += 1;
  1413. });
  1414. self.priority( priority ); // Make sure sidebar control remains at end
  1415. // Re-sort widget form controls (including widgets form other sidebars newly moved here)
  1416. self._applyCardinalOrderClassNames();
  1417. // If the widget was dragged into the sidebar, make sure the sidebar_id param is updated
  1418. _( widgetFormControls ).each( function( widgetFormControl ) {
  1419. widgetFormControl.params.sidebar_id = self.params.sidebar_id;
  1420. } );
  1421. // Cleanup after widget removal
  1422. _( removedWidgetIds ).each( function( removedWidgetId ) {
  1423. // Using setTimeout so that when moving a widget to another sidebar, the other sidebars_widgets settings get a chance to update
  1424. setTimeout( function() {
  1425. var removedControl, wasDraggedToAnotherSidebar, inactiveWidgets, removedIdBase,
  1426. widget, isPresentInAnotherSidebar = false;
  1427. // Check if the widget is in another sidebar
  1428. api.each( function( otherSetting ) {
  1429. if ( otherSetting.id === self.setting.id || 0 !== otherSetting.id.indexOf( 'sidebars_widgets[' ) || otherSetting.id === 'sidebars_widgets[wp_in