PageRenderTime 31ms CodeModel.GetById 27ms RepoModel.GetById 0ms app.codeStats 1ms

/ljmc-admin/js/customize-widgets.js

https://bitbucket.org/lpservice-it/ljmc
JavaScript | 2024 lines | 1252 code | 322 blank | 450 comment | 221 complexity | 6689d404dc724c4706fe0171f020d5e6 MD5 | raw file
Possible License(s): Apache-2.0, GPL-3.0, MIT

Large files files are truncated, but you can click here to view the full file

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

Large files files are truncated, but you can click here to view the full file