customize-controls.js (291564B)
1 /** 2 * @output wp-admin/js/customize-controls.js 3 */ 4 5 /* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer, console, confirm */ 6 (function( exports, $ ){ 7 var Container, focus, normalizedTransitionendEventName, api = wp.customize; 8 9 api.OverlayNotification = api.Notification.extend(/** @lends wp.customize.OverlayNotification.prototype */{ 10 11 /** 12 * Whether the notification should show a loading spinner. 13 * 14 * @since 4.9.0 15 * @var {boolean} 16 */ 17 loading: false, 18 19 /** 20 * A notification that is displayed in a full-screen overlay. 21 * 22 * @constructs wp.customize.OverlayNotification 23 * @augments wp.customize.Notification 24 * 25 * @since 4.9.0 26 * 27 * @param {string} code - Code. 28 * @param {Object} params - Params. 29 */ 30 initialize: function( code, params ) { 31 var notification = this; 32 api.Notification.prototype.initialize.call( notification, code, params ); 33 notification.containerClasses += ' notification-overlay'; 34 if ( notification.loading ) { 35 notification.containerClasses += ' notification-loading'; 36 } 37 }, 38 39 /** 40 * Render notification. 41 * 42 * @since 4.9.0 43 * 44 * @return {jQuery} Notification container. 45 */ 46 render: function() { 47 var li = api.Notification.prototype.render.call( this ); 48 li.on( 'keydown', _.bind( this.handleEscape, this ) ); 49 return li; 50 }, 51 52 /** 53 * Stop propagation on escape key presses, but also dismiss notification if it is dismissible. 54 * 55 * @since 4.9.0 56 * 57 * @param {jQuery.Event} event - Event. 58 * @return {void} 59 */ 60 handleEscape: function( event ) { 61 var notification = this; 62 if ( 27 === event.which ) { 63 event.stopPropagation(); 64 if ( notification.dismissible && notification.parent ) { 65 notification.parent.remove( notification.code ); 66 } 67 } 68 } 69 }); 70 71 api.Notifications = api.Values.extend(/** @lends wp.customize.Notifications.prototype */{ 72 73 /** 74 * Whether the alternative style should be used. 75 * 76 * @since 4.9.0 77 * @type {boolean} 78 */ 79 alt: false, 80 81 /** 82 * The default constructor for items of the collection. 83 * 84 * @since 4.9.0 85 * @type {object} 86 */ 87 defaultConstructor: api.Notification, 88 89 /** 90 * A collection of observable notifications. 91 * 92 * @since 4.9.0 93 * 94 * @constructs wp.customize.Notifications 95 * @augments wp.customize.Values 96 * 97 * @param {Object} options - Options. 98 * @param {jQuery} [options.container] - Container element for notifications. This can be injected later. 99 * @param {boolean} [options.alt] - Whether alternative style should be used when rendering notifications. 100 * 101 * @return {void} 102 */ 103 initialize: function( options ) { 104 var collection = this; 105 106 api.Values.prototype.initialize.call( collection, options ); 107 108 _.bindAll( collection, 'constrainFocus' ); 109 110 // Keep track of the order in which the notifications were added for sorting purposes. 111 collection._addedIncrement = 0; 112 collection._addedOrder = {}; 113 114 // Trigger change event when notification is added or removed. 115 collection.bind( 'add', function( notification ) { 116 collection.trigger( 'change', notification ); 117 }); 118 collection.bind( 'removed', function( notification ) { 119 collection.trigger( 'change', notification ); 120 }); 121 }, 122 123 /** 124 * Get the number of notifications added. 125 * 126 * @since 4.9.0 127 * @return {number} Count of notifications. 128 */ 129 count: function() { 130 return _.size( this._value ); 131 }, 132 133 /** 134 * Add notification to the collection. 135 * 136 * @since 4.9.0 137 * 138 * @param {string|wp.customize.Notification} notification - Notification object to add. Alternatively code may be supplied, and in that case the second notificationObject argument must be supplied. 139 * @param {wp.customize.Notification} [notificationObject] - Notification to add when first argument is the code string. 140 * @return {wp.customize.Notification} Added notification (or existing instance if it was already added). 141 */ 142 add: function( notification, notificationObject ) { 143 var collection = this, code, instance; 144 if ( 'string' === typeof notification ) { 145 code = notification; 146 instance = notificationObject; 147 } else { 148 code = notification.code; 149 instance = notification; 150 } 151 if ( ! collection.has( code ) ) { 152 collection._addedIncrement += 1; 153 collection._addedOrder[ code ] = collection._addedIncrement; 154 } 155 return api.Values.prototype.add.call( collection, code, instance ); 156 }, 157 158 /** 159 * Add notification to the collection. 160 * 161 * @since 4.9.0 162 * @param {string} code - Notification code to remove. 163 * @return {api.Notification} Added instance (or existing instance if it was already added). 164 */ 165 remove: function( code ) { 166 var collection = this; 167 delete collection._addedOrder[ code ]; 168 return api.Values.prototype.remove.call( this, code ); 169 }, 170 171 /** 172 * Get list of notifications. 173 * 174 * Notifications may be sorted by type followed by added time. 175 * 176 * @since 4.9.0 177 * @param {Object} args - Args. 178 * @param {boolean} [args.sort=false] - Whether to return the notifications sorted. 179 * @return {Array.<wp.customize.Notification>} Notifications. 180 */ 181 get: function( args ) { 182 var collection = this, notifications, errorTypePriorities, params; 183 notifications = _.values( collection._value ); 184 185 params = _.extend( 186 { sort: false }, 187 args 188 ); 189 190 if ( params.sort ) { 191 errorTypePriorities = { error: 4, warning: 3, success: 2, info: 1 }; 192 notifications.sort( function( a, b ) { 193 var aPriority = 0, bPriority = 0; 194 if ( ! _.isUndefined( errorTypePriorities[ a.type ] ) ) { 195 aPriority = errorTypePriorities[ a.type ]; 196 } 197 if ( ! _.isUndefined( errorTypePriorities[ b.type ] ) ) { 198 bPriority = errorTypePriorities[ b.type ]; 199 } 200 if ( aPriority !== bPriority ) { 201 return bPriority - aPriority; // Show errors first. 202 } 203 return collection._addedOrder[ b.code ] - collection._addedOrder[ a.code ]; // Show newer notifications higher. 204 }); 205 } 206 207 return notifications; 208 }, 209 210 /** 211 * Render notifications area. 212 * 213 * @since 4.9.0 214 * @return {void} 215 */ 216 render: function() { 217 var collection = this, 218 notifications, hadOverlayNotification = false, hasOverlayNotification, overlayNotifications = [], 219 previousNotificationsByCode = {}, 220 listElement, focusableElements; 221 222 // Short-circuit if there are no container to render into. 223 if ( ! collection.container || ! collection.container.length ) { 224 return; 225 } 226 227 notifications = collection.get( { sort: true } ); 228 collection.container.toggle( 0 !== notifications.length ); 229 230 // Short-circuit if there are no changes to the notifications. 231 if ( collection.container.is( collection.previousContainer ) && _.isEqual( notifications, collection.previousNotifications ) ) { 232 return; 233 } 234 235 // Make sure list is part of the container. 236 listElement = collection.container.children( 'ul' ).first(); 237 if ( ! listElement.length ) { 238 listElement = $( '<ul></ul>' ); 239 collection.container.append( listElement ); 240 } 241 242 // Remove all notifications prior to re-rendering. 243 listElement.find( '> [data-code]' ).remove(); 244 245 _.each( collection.previousNotifications, function( notification ) { 246 previousNotificationsByCode[ notification.code ] = notification; 247 }); 248 249 // Add all notifications in the sorted order. 250 _.each( notifications, function( notification ) { 251 var notificationContainer; 252 if ( wp.a11y && ( ! previousNotificationsByCode[ notification.code ] || ! _.isEqual( notification.message, previousNotificationsByCode[ notification.code ].message ) ) ) { 253 wp.a11y.speak( notification.message, 'assertive' ); 254 } 255 notificationContainer = $( notification.render() ); 256 notification.container = notificationContainer; 257 listElement.append( notificationContainer ); // @todo Consider slideDown() as enhancement. 258 259 if ( notification.extended( api.OverlayNotification ) ) { 260 overlayNotifications.push( notification ); 261 } 262 }); 263 hasOverlayNotification = Boolean( overlayNotifications.length ); 264 265 if ( collection.previousNotifications ) { 266 hadOverlayNotification = Boolean( _.find( collection.previousNotifications, function( notification ) { 267 return notification.extended( api.OverlayNotification ); 268 } ) ); 269 } 270 271 if ( hasOverlayNotification !== hadOverlayNotification ) { 272 $( document.body ).toggleClass( 'customize-loading', hasOverlayNotification ); 273 collection.container.toggleClass( 'has-overlay-notifications', hasOverlayNotification ); 274 if ( hasOverlayNotification ) { 275 collection.previousActiveElement = document.activeElement; 276 $( document ).on( 'keydown', collection.constrainFocus ); 277 } else { 278 $( document ).off( 'keydown', collection.constrainFocus ); 279 } 280 } 281 282 if ( hasOverlayNotification ) { 283 collection.focusContainer = overlayNotifications[ overlayNotifications.length - 1 ].container; 284 collection.focusContainer.prop( 'tabIndex', -1 ); 285 focusableElements = collection.focusContainer.find( ':focusable' ); 286 if ( focusableElements.length ) { 287 focusableElements.first().focus(); 288 } else { 289 collection.focusContainer.focus(); 290 } 291 } else if ( collection.previousActiveElement ) { 292 $( collection.previousActiveElement ).trigger( 'focus' ); 293 collection.previousActiveElement = null; 294 } 295 296 collection.previousNotifications = notifications; 297 collection.previousContainer = collection.container; 298 collection.trigger( 'rendered' ); 299 }, 300 301 /** 302 * Constrain focus on focus container. 303 * 304 * @since 4.9.0 305 * 306 * @param {jQuery.Event} event - Event. 307 * @return {void} 308 */ 309 constrainFocus: function constrainFocus( event ) { 310 var collection = this, focusableElements; 311 312 // Prevent keys from escaping. 313 event.stopPropagation(); 314 315 if ( 9 !== event.which ) { // Tab key. 316 return; 317 } 318 319 focusableElements = collection.focusContainer.find( ':focusable' ); 320 if ( 0 === focusableElements.length ) { 321 focusableElements = collection.focusContainer; 322 } 323 324 if ( ! $.contains( collection.focusContainer[0], event.target ) || ! $.contains( collection.focusContainer[0], document.activeElement ) ) { 325 event.preventDefault(); 326 focusableElements.first().focus(); 327 } else if ( focusableElements.last().is( event.target ) && ! event.shiftKey ) { 328 event.preventDefault(); 329 focusableElements.first().focus(); 330 } else if ( focusableElements.first().is( event.target ) && event.shiftKey ) { 331 event.preventDefault(); 332 focusableElements.last().focus(); 333 } 334 } 335 }); 336 337 api.Setting = api.Value.extend(/** @lends wp.customize.Setting.prototype */{ 338 339 /** 340 * Default params. 341 * 342 * @since 4.9.0 343 * @var {object} 344 */ 345 defaults: { 346 transport: 'refresh', 347 dirty: false 348 }, 349 350 /** 351 * A Customizer Setting. 352 * 353 * A setting is WordPress data (theme mod, option, menu, etc.) that the user can 354 * draft changes to in the Customizer. 355 * 356 * @see PHP class WP_Customize_Setting. 357 * 358 * @constructs wp.customize.Setting 359 * @augments wp.customize.Value 360 * 361 * @since 3.4.0 362 * 363 * @param {string} id - The setting ID. 364 * @param {*} value - The initial value of the setting. 365 * @param {Object} [options={}] - Options. 366 * @param {string} [options.transport=refresh] - The transport to use for previewing. Supports 'refresh' and 'postMessage'. 367 * @param {boolean} [options.dirty=false] - Whether the setting should be considered initially dirty. 368 * @param {Object} [options.previewer] - The Previewer instance to sync with. Defaults to wp.customize.previewer. 369 */ 370 initialize: function( id, value, options ) { 371 var setting = this, params; 372 params = _.extend( 373 { previewer: api.previewer }, 374 setting.defaults, 375 options || {} 376 ); 377 378 api.Value.prototype.initialize.call( setting, value, params ); 379 380 setting.id = id; 381 setting._dirty = params.dirty; // The _dirty property is what the Customizer reads from. 382 setting.notifications = new api.Notifications(); 383 384 // Whenever the setting's value changes, refresh the preview. 385 setting.bind( setting.preview ); 386 }, 387 388 /** 389 * Refresh the preview, respective of the setting's refresh policy. 390 * 391 * If the preview hasn't sent a keep-alive message and is likely 392 * disconnected by having navigated to a non-allowed URL, then the 393 * refresh transport will be forced when postMessage is the transport. 394 * Note that postMessage does not throw an error when the recipient window 395 * fails to match the origin window, so using try/catch around the 396 * previewer.send() call to then fallback to refresh will not work. 397 * 398 * @since 3.4.0 399 * @access public 400 * 401 * @return {void} 402 */ 403 preview: function() { 404 var setting = this, transport; 405 transport = setting.transport; 406 407 if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) { 408 transport = 'refresh'; 409 } 410 411 if ( 'postMessage' === transport ) { 412 setting.previewer.send( 'setting', [ setting.id, setting() ] ); 413 } else if ( 'refresh' === transport ) { 414 setting.previewer.refresh(); 415 } 416 }, 417 418 /** 419 * Find controls associated with this setting. 420 * 421 * @since 4.6.0 422 * @return {wp.customize.Control[]} Controls associated with setting. 423 */ 424 findControls: function() { 425 var setting = this, controls = []; 426 api.control.each( function( control ) { 427 _.each( control.settings, function( controlSetting ) { 428 if ( controlSetting.id === setting.id ) { 429 controls.push( control ); 430 } 431 } ); 432 } ); 433 return controls; 434 } 435 }); 436 437 /** 438 * Current change count. 439 * 440 * @alias wp.customize._latestRevision 441 * 442 * @since 4.7.0 443 * @type {number} 444 * @protected 445 */ 446 api._latestRevision = 0; 447 448 /** 449 * Last revision that was saved. 450 * 451 * @alias wp.customize._lastSavedRevision 452 * 453 * @since 4.7.0 454 * @type {number} 455 * @protected 456 */ 457 api._lastSavedRevision = 0; 458 459 /** 460 * Latest revisions associated with the updated setting. 461 * 462 * @alias wp.customize._latestSettingRevisions 463 * 464 * @since 4.7.0 465 * @type {object} 466 * @protected 467 */ 468 api._latestSettingRevisions = {}; 469 470 /* 471 * Keep track of the revision associated with each updated setting so that 472 * requestChangesetUpdate knows which dirty settings to include. Also, once 473 * ready is triggered and all initial settings have been added, increment 474 * revision for each newly-created initially-dirty setting so that it will 475 * also be included in changeset update requests. 476 */ 477 api.bind( 'change', function incrementChangedSettingRevision( setting ) { 478 api._latestRevision += 1; 479 api._latestSettingRevisions[ setting.id ] = api._latestRevision; 480 } ); 481 api.bind( 'ready', function() { 482 api.bind( 'add', function incrementCreatedSettingRevision( setting ) { 483 if ( setting._dirty ) { 484 api._latestRevision += 1; 485 api._latestSettingRevisions[ setting.id ] = api._latestRevision; 486 } 487 } ); 488 } ); 489 490 /** 491 * Get the dirty setting values. 492 * 493 * @alias wp.customize.dirtyValues 494 * 495 * @since 4.7.0 496 * @access public 497 * 498 * @param {Object} [options] Options. 499 * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes). 500 * @return {Object} Dirty setting values. 501 */ 502 api.dirtyValues = function dirtyValues( options ) { 503 var values = {}; 504 api.each( function( setting ) { 505 var settingRevision; 506 507 if ( ! setting._dirty ) { 508 return; 509 } 510 511 settingRevision = api._latestSettingRevisions[ setting.id ]; 512 513 // Skip including settings that have already been included in the changeset, if only requesting unsaved. 514 if ( api.state( 'changesetStatus' ).get() && ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) { 515 return; 516 } 517 518 values[ setting.id ] = setting.get(); 519 } ); 520 return values; 521 }; 522 523 /** 524 * Request updates to the changeset. 525 * 526 * @alias wp.customize.requestChangesetUpdate 527 * 528 * @since 4.7.0 529 * @access public 530 * 531 * @param {Object} [changes] - Mapping of setting IDs to setting params each normally including a value property, or mapping to null. 532 * If not provided, then the changes will still be obtained from unsaved dirty settings. 533 * @param {Object} [args] - Additional options for the save request. 534 * @param {boolean} [args.autosave=false] - Whether changes will be stored in autosave revision if the changeset has been promoted from an auto-draft. 535 * @param {boolean} [args.force=false] - Send request to update even when there are no changes to submit. This can be used to request the latest status of the changeset on the server. 536 * @param {string} [args.title] - Title to update in the changeset. Optional. 537 * @param {string} [args.date] - Date to update in the changeset. Optional. 538 * @return {jQuery.Promise} Promise resolving with the response data. 539 */ 540 api.requestChangesetUpdate = function requestChangesetUpdate( changes, args ) { 541 var deferred, request, submittedChanges = {}, data, submittedArgs; 542 deferred = new $.Deferred(); 543 544 // Prevent attempting changeset update while request is being made. 545 if ( 0 !== api.state( 'processing' ).get() ) { 546 deferred.reject( 'already_processing' ); 547 return deferred.promise(); 548 } 549 550 submittedArgs = _.extend( { 551 title: null, 552 date: null, 553 autosave: false, 554 force: false 555 }, args ); 556 557 if ( changes ) { 558 _.extend( submittedChanges, changes ); 559 } 560 561 // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes. 562 _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) { 563 if ( ! changes || null !== changes[ settingId ] ) { 564 submittedChanges[ settingId ] = _.extend( 565 {}, 566 submittedChanges[ settingId ] || {}, 567 { value: dirtyValue } 568 ); 569 } 570 } ); 571 572 // Allow plugins to attach additional params to the settings. 573 api.trigger( 'changeset-save', submittedChanges, submittedArgs ); 574 575 // Short-circuit when there are no pending changes. 576 if ( ! submittedArgs.force && _.isEmpty( submittedChanges ) && null === submittedArgs.title && null === submittedArgs.date ) { 577 deferred.resolve( {} ); 578 return deferred.promise(); 579 } 580 581 // A status would cause a revision to be made, and for this wp.customize.previewer.save() should be used. 582 // Status is also disallowed for revisions regardless. 583 if ( submittedArgs.status ) { 584 return deferred.reject( { code: 'illegal_status_in_changeset_update' } ).promise(); 585 } 586 587 // Dates not beung allowed for revisions are is a technical limitation of post revisions. 588 if ( submittedArgs.date && submittedArgs.autosave ) { 589 return deferred.reject( { code: 'illegal_autosave_with_date_gmt' } ).promise(); 590 } 591 592 // Make sure that publishing a changeset waits for all changeset update requests to complete. 593 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 594 deferred.always( function() { 595 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 596 } ); 597 598 // Ensure that if any plugins add data to save requests by extending query() that they get included here. 599 data = api.previewer.query( { excludeCustomizedSaved: true } ); 600 delete data.customized; // Being sent in customize_changeset_data instead. 601 _.extend( data, { 602 nonce: api.settings.nonce.save, 603 customize_theme: api.settings.theme.stylesheet, 604 customize_changeset_data: JSON.stringify( submittedChanges ) 605 } ); 606 if ( null !== submittedArgs.title ) { 607 data.customize_changeset_title = submittedArgs.title; 608 } 609 if ( null !== submittedArgs.date ) { 610 data.customize_changeset_date = submittedArgs.date; 611 } 612 if ( false !== submittedArgs.autosave ) { 613 data.customize_changeset_autosave = 'true'; 614 } 615 616 // Allow plugins to modify the params included with the save request. 617 api.trigger( 'save-request-params', data ); 618 619 request = wp.ajax.post( 'customize_save', data ); 620 621 request.done( function requestChangesetUpdateDone( data ) { 622 var savedChangesetValues = {}; 623 624 // Ensure that all settings updated subsequently will be included in the next changeset update request. 625 api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision ); 626 627 api.state( 'changesetStatus' ).set( data.changeset_status ); 628 629 if ( data.changeset_date ) { 630 api.state( 'changesetDate' ).set( data.changeset_date ); 631 } 632 633 deferred.resolve( data ); 634 api.trigger( 'changeset-saved', data ); 635 636 if ( data.setting_validities ) { 637 _.each( data.setting_validities, function( validity, settingId ) { 638 if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) { 639 savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value; 640 } 641 } ); 642 } 643 644 api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) ); 645 } ); 646 request.fail( function requestChangesetUpdateFail( data ) { 647 deferred.reject( data ); 648 api.trigger( 'changeset-error', data ); 649 } ); 650 request.always( function( data ) { 651 if ( data.setting_validities ) { 652 api._handleSettingValidities( { 653 settingValidities: data.setting_validities 654 } ); 655 } 656 } ); 657 658 return deferred.promise(); 659 }; 660 661 /** 662 * Watch all changes to Value properties, and bubble changes to parent Values instance 663 * 664 * @alias wp.customize.utils.bubbleChildValueChanges 665 * 666 * @since 4.1.0 667 * 668 * @param {wp.customize.Class} instance 669 * @param {Array} properties The names of the Value instances to watch. 670 */ 671 api.utils.bubbleChildValueChanges = function ( instance, properties ) { 672 $.each( properties, function ( i, key ) { 673 instance[ key ].bind( function ( to, from ) { 674 if ( instance.parent && to !== from ) { 675 instance.parent.trigger( 'change', instance ); 676 } 677 } ); 678 } ); 679 }; 680 681 /** 682 * Expand a panel, section, or control and focus on the first focusable element. 683 * 684 * @alias wp.customize~focus 685 * 686 * @since 4.1.0 687 * 688 * @param {Object} [params] 689 * @param {Function} [params.completeCallback] 690 */ 691 focus = function ( params ) { 692 var construct, completeCallback, focus, focusElement; 693 construct = this; 694 params = params || {}; 695 focus = function () { 696 var focusContainer; 697 if ( ( construct.extended( api.Panel ) || construct.extended( api.Section ) ) && construct.expanded && construct.expanded() ) { 698 focusContainer = construct.contentContainer; 699 } else { 700 focusContainer = construct.container; 701 } 702 703 focusElement = focusContainer.find( '.control-focus:first' ); 704 if ( 0 === focusElement.length ) { 705 // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583 706 focusElement = focusContainer.find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' ).first(); 707 } 708 focusElement.focus(); 709 }; 710 if ( params.completeCallback ) { 711 completeCallback = params.completeCallback; 712 params.completeCallback = function () { 713 focus(); 714 completeCallback(); 715 }; 716 } else { 717 params.completeCallback = focus; 718 } 719 720 api.state( 'paneVisible' ).set( true ); 721 if ( construct.expand ) { 722 construct.expand( params ); 723 } else { 724 params.completeCallback(); 725 } 726 }; 727 728 /** 729 * Stable sort for Panels, Sections, and Controls. 730 * 731 * If a.priority() === b.priority(), then sort by their respective params.instanceNumber. 732 * 733 * @alias wp.customize.utils.prioritySort 734 * 735 * @since 4.1.0 736 * 737 * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} a 738 * @param {(wp.customize.Panel|wp.customize.Section|wp.customize.Control)} b 739 * @return {number} 740 */ 741 api.utils.prioritySort = function ( a, b ) { 742 if ( a.priority() === b.priority() && typeof a.params.instanceNumber === 'number' && typeof b.params.instanceNumber === 'number' ) { 743 return a.params.instanceNumber - b.params.instanceNumber; 744 } else { 745 return a.priority() - b.priority(); 746 } 747 }; 748 749 /** 750 * Return whether the supplied Event object is for a keydown event but not the Enter key. 751 * 752 * @alias wp.customize.utils.isKeydownButNotEnterEvent 753 * 754 * @since 4.1.0 755 * 756 * @param {jQuery.Event} event 757 * @return {boolean} 758 */ 759 api.utils.isKeydownButNotEnterEvent = function ( event ) { 760 return ( 'keydown' === event.type && 13 !== event.which ); 761 }; 762 763 /** 764 * Return whether the two lists of elements are the same and are in the same order. 765 * 766 * @alias wp.customize.utils.areElementListsEqual 767 * 768 * @since 4.1.0 769 * 770 * @param {Array|jQuery} listA 771 * @param {Array|jQuery} listB 772 * @return {boolean} 773 */ 774 api.utils.areElementListsEqual = function ( listA, listB ) { 775 var equal = ( 776 listA.length === listB.length && // If lists are different lengths, then naturally they are not equal. 777 -1 === _.indexOf( _.map( // Are there any false values in the list returned by map? 778 _.zip( listA, listB ), // Pair up each element between the two lists. 779 function ( pair ) { 780 return $( pair[0] ).is( pair[1] ); // Compare to see if each pair is equal. 781 } 782 ), false ) // Check for presence of false in map's return value. 783 ); 784 return equal; 785 }; 786 787 /** 788 * Highlight the existence of a button. 789 * 790 * This function reminds the user of a button represented by the specified 791 * UI element, after an optional delay. If the user focuses the element 792 * before the delay passes, the reminder is canceled. 793 * 794 * @alias wp.customize.utils.highlightButton 795 * 796 * @since 4.9.0 797 * 798 * @param {jQuery} button - The element to highlight. 799 * @param {Object} [options] - Options. 800 * @param {number} [options.delay=0] - Delay in milliseconds. 801 * @param {jQuery} [options.focusTarget] - A target for user focus that defaults to the highlighted element. 802 * If the user focuses the target before the delay passes, the reminder 803 * is canceled. This option exists to accommodate compound buttons 804 * containing auxiliary UI, such as the Publish button augmented with a 805 * Settings button. 806 * @return {Function} An idempotent function that cancels the reminder. 807 */ 808 api.utils.highlightButton = function highlightButton( button, options ) { 809 var animationClass = 'button-see-me', 810 canceled = false, 811 params; 812 813 params = _.extend( 814 { 815 delay: 0, 816 focusTarget: button 817 }, 818 options 819 ); 820 821 function cancelReminder() { 822 canceled = true; 823 } 824 825 params.focusTarget.on( 'focusin', cancelReminder ); 826 setTimeout( function() { 827 params.focusTarget.off( 'focusin', cancelReminder ); 828 829 if ( ! canceled ) { 830 button.addClass( animationClass ); 831 button.one( 'animationend', function() { 832 /* 833 * Remove animation class to avoid situations in Customizer where 834 * DOM nodes are moved (re-inserted) and the animation repeats. 835 */ 836 button.removeClass( animationClass ); 837 } ); 838 } 839 }, params.delay ); 840 841 return cancelReminder; 842 }; 843 844 /** 845 * Get current timestamp adjusted for server clock time. 846 * 847 * Same functionality as the `current_time( 'mysql', false )` function in PHP. 848 * 849 * @alias wp.customize.utils.getCurrentTimestamp 850 * 851 * @since 4.9.0 852 * 853 * @return {number} Current timestamp. 854 */ 855 api.utils.getCurrentTimestamp = function getCurrentTimestamp() { 856 var currentDate, currentClientTimestamp, timestampDifferential; 857 currentClientTimestamp = _.now(); 858 currentDate = new Date( api.settings.initialServerDate.replace( /-/g, '/' ) ); 859 timestampDifferential = currentClientTimestamp - api.settings.initialClientTimestamp; 860 timestampDifferential += api.settings.initialClientTimestamp - api.settings.initialServerTimestamp; 861 currentDate.setTime( currentDate.getTime() + timestampDifferential ); 862 return currentDate.getTime(); 863 }; 864 865 /** 866 * Get remaining time of when the date is set. 867 * 868 * @alias wp.customize.utils.getRemainingTime 869 * 870 * @since 4.9.0 871 * 872 * @param {string|number|Date} datetime - Date time or timestamp of the future date. 873 * @return {number} remainingTime - Remaining time in milliseconds. 874 */ 875 api.utils.getRemainingTime = function getRemainingTime( datetime ) { 876 var millisecondsDivider = 1000, remainingTime, timestamp; 877 if ( datetime instanceof Date ) { 878 timestamp = datetime.getTime(); 879 } else if ( 'string' === typeof datetime ) { 880 timestamp = ( new Date( datetime.replace( /-/g, '/' ) ) ).getTime(); 881 } else { 882 timestamp = datetime; 883 } 884 885 remainingTime = timestamp - api.utils.getCurrentTimestamp(); 886 remainingTime = Math.ceil( remainingTime / millisecondsDivider ); 887 return remainingTime; 888 }; 889 890 /** 891 * Return browser supported `transitionend` event name. 892 * 893 * @since 4.7.0 894 * 895 * @ignore 896 * 897 * @return {string|null} Normalized `transitionend` event name or null if CSS transitions are not supported. 898 */ 899 normalizedTransitionendEventName = (function () { 900 var el, transitions, prop; 901 el = document.createElement( 'div' ); 902 transitions = { 903 'transition' : 'transitionend', 904 'OTransition' : 'oTransitionEnd', 905 'MozTransition' : 'transitionend', 906 'WebkitTransition': 'webkitTransitionEnd' 907 }; 908 prop = _.find( _.keys( transitions ), function( prop ) { 909 return ! _.isUndefined( el.style[ prop ] ); 910 } ); 911 if ( prop ) { 912 return transitions[ prop ]; 913 } else { 914 return null; 915 } 916 })(); 917 918 Container = api.Class.extend(/** @lends wp.customize~Container.prototype */{ 919 defaultActiveArguments: { duration: 'fast', completeCallback: $.noop }, 920 defaultExpandedArguments: { duration: 'fast', completeCallback: $.noop }, 921 containerType: 'container', 922 defaults: { 923 title: '', 924 description: '', 925 priority: 100, 926 type: 'default', 927 content: null, 928 active: true, 929 instanceNumber: null 930 }, 931 932 /** 933 * Base class for Panel and Section. 934 * 935 * @constructs wp.customize~Container 936 * @augments wp.customize.Class 937 * 938 * @since 4.1.0 939 * 940 * @borrows wp.customize~focus as focus 941 * 942 * @param {string} id - The ID for the container. 943 * @param {Object} options - Object containing one property: params. 944 * @param {string} options.title - Title shown when panel is collapsed and expanded. 945 * @param {string} [options.description] - Description shown at the top of the panel. 946 * @param {number} [options.priority=100] - The sort priority for the panel. 947 * @param {string} [options.templateId] - Template selector for container. 948 * @param {string} [options.type=default] - The type of the panel. See wp.customize.panelConstructor. 949 * @param {string} [options.content] - The markup to be used for the panel container. If empty, a JS template is used. 950 * @param {boolean} [options.active=true] - Whether the panel is active or not. 951 * @param {Object} [options.params] - Deprecated wrapper for the above properties. 952 */ 953 initialize: function ( id, options ) { 954 var container = this; 955 container.id = id; 956 957 if ( ! Container.instanceCounter ) { 958 Container.instanceCounter = 0; 959 } 960 Container.instanceCounter++; 961 962 $.extend( container, { 963 params: _.defaults( 964 options.params || options, // Passing the params is deprecated. 965 container.defaults 966 ) 967 } ); 968 if ( ! container.params.instanceNumber ) { 969 container.params.instanceNumber = Container.instanceCounter; 970 } 971 container.notifications = new api.Notifications(); 972 container.templateSelector = container.params.templateId || 'customize-' + container.containerType + '-' + container.params.type; 973 container.container = $( container.params.content ); 974 if ( 0 === container.container.length ) { 975 container.container = $( container.getContainer() ); 976 } 977 container.headContainer = container.container; 978 container.contentContainer = container.getContent(); 979 container.container = container.container.add( container.contentContainer ); 980 981 container.deferred = { 982 embedded: new $.Deferred() 983 }; 984 container.priority = new api.Value(); 985 container.active = new api.Value(); 986 container.activeArgumentsQueue = []; 987 container.expanded = new api.Value(); 988 container.expandedArgumentsQueue = []; 989 990 container.active.bind( function ( active ) { 991 var args = container.activeArgumentsQueue.shift(); 992 args = $.extend( {}, container.defaultActiveArguments, args ); 993 active = ( active && container.isContextuallyActive() ); 994 container.onChangeActive( active, args ); 995 }); 996 container.expanded.bind( function ( expanded ) { 997 var args = container.expandedArgumentsQueue.shift(); 998 args = $.extend( {}, container.defaultExpandedArguments, args ); 999 container.onChangeExpanded( expanded, args ); 1000 }); 1001 1002 container.deferred.embedded.done( function () { 1003 container.setupNotifications(); 1004 container.attachEvents(); 1005 }); 1006 1007 api.utils.bubbleChildValueChanges( container, [ 'priority', 'active' ] ); 1008 1009 container.priority.set( container.params.priority ); 1010 container.active.set( container.params.active ); 1011 container.expanded.set( false ); 1012 }, 1013 1014 /** 1015 * Get the element that will contain the notifications. 1016 * 1017 * @since 4.9.0 1018 * @return {jQuery} Notification container element. 1019 */ 1020 getNotificationsContainerElement: function() { 1021 var container = this; 1022 return container.contentContainer.find( '.customize-control-notifications-container:first' ); 1023 }, 1024 1025 /** 1026 * Set up notifications. 1027 * 1028 * @since 4.9.0 1029 * @return {void} 1030 */ 1031 setupNotifications: function() { 1032 var container = this, renderNotifications; 1033 container.notifications.container = container.getNotificationsContainerElement(); 1034 1035 // Render notifications when they change and when the construct is expanded. 1036 renderNotifications = function() { 1037 if ( container.expanded.get() ) { 1038 container.notifications.render(); 1039 } 1040 }; 1041 container.expanded.bind( renderNotifications ); 1042 renderNotifications(); 1043 container.notifications.bind( 'change', _.debounce( renderNotifications ) ); 1044 }, 1045 1046 /** 1047 * @since 4.1.0 1048 * 1049 * @abstract 1050 */ 1051 ready: function() {}, 1052 1053 /** 1054 * Get the child models associated with this parent, sorting them by their priority Value. 1055 * 1056 * @since 4.1.0 1057 * 1058 * @param {string} parentType 1059 * @param {string} childType 1060 * @return {Array} 1061 */ 1062 _children: function ( parentType, childType ) { 1063 var parent = this, 1064 children = []; 1065 api[ childType ].each( function ( child ) { 1066 if ( child[ parentType ].get() === parent.id ) { 1067 children.push( child ); 1068 } 1069 } ); 1070 children.sort( api.utils.prioritySort ); 1071 return children; 1072 }, 1073 1074 /** 1075 * To override by subclass, to return whether the container has active children. 1076 * 1077 * @since 4.1.0 1078 * 1079 * @abstract 1080 */ 1081 isContextuallyActive: function () { 1082 throw new Error( 'Container.isContextuallyActive() must be overridden in a subclass.' ); 1083 }, 1084 1085 /** 1086 * Active state change handler. 1087 * 1088 * Shows the container if it is active, hides it if not. 1089 * 1090 * To override by subclass, update the container's UI to reflect the provided active state. 1091 * 1092 * @since 4.1.0 1093 * 1094 * @param {boolean} active - The active state to transiution to. 1095 * @param {Object} [args] - Args. 1096 * @param {Object} [args.duration] - The duration for the slideUp/slideDown animation. 1097 * @param {boolean} [args.unchanged] - Whether the state is already known to not be changed, and so short-circuit with calling completeCallback early. 1098 * @param {Function} [args.completeCallback] - Function to call when the slideUp/slideDown has completed. 1099 */ 1100 onChangeActive: function( active, args ) { 1101 var construct = this, 1102 headContainer = construct.headContainer, 1103 duration, expandedOtherPanel; 1104 1105 if ( args.unchanged ) { 1106 if ( args.completeCallback ) { 1107 args.completeCallback(); 1108 } 1109 return; 1110 } 1111 1112 duration = ( 'resolved' === api.previewer.deferred.active.state() ? args.duration : 0 ); 1113 1114 if ( construct.extended( api.Panel ) ) { 1115 // If this is a panel is not currently expanded but another panel is expanded, do not animate. 1116 api.panel.each(function ( panel ) { 1117 if ( panel !== construct && panel.expanded() ) { 1118 expandedOtherPanel = panel; 1119 duration = 0; 1120 } 1121 }); 1122 1123 // Collapse any expanded sections inside of this panel first before deactivating. 1124 if ( ! active ) { 1125 _.each( construct.sections(), function( section ) { 1126 section.collapse( { duration: 0 } ); 1127 } ); 1128 } 1129 } 1130 1131 if ( ! $.contains( document, headContainer.get( 0 ) ) ) { 1132 // If the element is not in the DOM, then jQuery.fn.slideUp() does nothing. 1133 // In this case, a hard toggle is required instead. 1134 headContainer.toggle( active ); 1135 if ( args.completeCallback ) { 1136 args.completeCallback(); 1137 } 1138 } else if ( active ) { 1139 headContainer.slideDown( duration, args.completeCallback ); 1140 } else { 1141 if ( construct.expanded() ) { 1142 construct.collapse({ 1143 duration: duration, 1144 completeCallback: function() { 1145 headContainer.slideUp( duration, args.completeCallback ); 1146 } 1147 }); 1148 } else { 1149 headContainer.slideUp( duration, args.completeCallback ); 1150 } 1151 } 1152 }, 1153 1154 /** 1155 * @since 4.1.0 1156 * 1157 * @param {boolean} active 1158 * @param {Object} [params] 1159 * @return {boolean} False if state already applied. 1160 */ 1161 _toggleActive: function ( active, params ) { 1162 var self = this; 1163 params = params || {}; 1164 if ( ( active && this.active.get() ) || ( ! active && ! this.active.get() ) ) { 1165 params.unchanged = true; 1166 self.onChangeActive( self.active.get(), params ); 1167 return false; 1168 } else { 1169 params.unchanged = false; 1170 this.activeArgumentsQueue.push( params ); 1171 this.active.set( active ); 1172 return true; 1173 } 1174 }, 1175 1176 /** 1177 * @param {Object} [params] 1178 * @return {boolean} False if already active. 1179 */ 1180 activate: function ( params ) { 1181 return this._toggleActive( true, params ); 1182 }, 1183 1184 /** 1185 * @param {Object} [params] 1186 * @return {boolean} False if already inactive. 1187 */ 1188 deactivate: function ( params ) { 1189 return this._toggleActive( false, params ); 1190 }, 1191 1192 /** 1193 * To override by subclass, update the container's UI to reflect the provided active state. 1194 * @abstract 1195 */ 1196 onChangeExpanded: function () { 1197 throw new Error( 'Must override with subclass.' ); 1198 }, 1199 1200 /** 1201 * Handle the toggle logic for expand/collapse. 1202 * 1203 * @param {boolean} expanded - The new state to apply. 1204 * @param {Object} [params] - Object containing options for expand/collapse. 1205 * @param {Function} [params.completeCallback] - Function to call when expansion/collapse is complete. 1206 * @return {boolean} False if state already applied or active state is false. 1207 */ 1208 _toggleExpanded: function( expanded, params ) { 1209 var instance = this, previousCompleteCallback; 1210 params = params || {}; 1211 previousCompleteCallback = params.completeCallback; 1212 1213 // Short-circuit expand() if the instance is not active. 1214 if ( expanded && ! instance.active() ) { 1215 return false; 1216 } 1217 1218 api.state( 'paneVisible' ).set( true ); 1219 params.completeCallback = function() { 1220 if ( previousCompleteCallback ) { 1221 previousCompleteCallback.apply( instance, arguments ); 1222 } 1223 if ( expanded ) { 1224 instance.container.trigger( 'expanded' ); 1225 } else { 1226 instance.container.trigger( 'collapsed' ); 1227 } 1228 }; 1229 if ( ( expanded && instance.expanded.get() ) || ( ! expanded && ! instance.expanded.get() ) ) { 1230 params.unchanged = true; 1231 instance.onChangeExpanded( instance.expanded.get(), params ); 1232 return false; 1233 } else { 1234 params.unchanged = false; 1235 instance.expandedArgumentsQueue.push( params ); 1236 instance.expanded.set( expanded ); 1237 return true; 1238 } 1239 }, 1240 1241 /** 1242 * @param {Object} [params] 1243 * @return {boolean} False if already expanded or if inactive. 1244 */ 1245 expand: function ( params ) { 1246 return this._toggleExpanded( true, params ); 1247 }, 1248 1249 /** 1250 * @param {Object} [params] 1251 * @return {boolean} False if already collapsed. 1252 */ 1253 collapse: function ( params ) { 1254 return this._toggleExpanded( false, params ); 1255 }, 1256 1257 /** 1258 * Animate container state change if transitions are supported by the browser. 1259 * 1260 * @since 4.7.0 1261 * @private 1262 * 1263 * @param {function} completeCallback Function to be called after transition is completed. 1264 * @return {void} 1265 */ 1266 _animateChangeExpanded: function( completeCallback ) { 1267 // Return if CSS transitions are not supported. 1268 if ( ! normalizedTransitionendEventName ) { 1269 if ( completeCallback ) { 1270 completeCallback(); 1271 } 1272 return; 1273 } 1274 1275 var construct = this, 1276 content = construct.contentContainer, 1277 overlay = content.closest( '.wp-full-overlay' ), 1278 elements, transitionEndCallback, transitionParentPane; 1279 1280 // Determine set of elements that are affected by the animation. 1281 elements = overlay.add( content ); 1282 1283 if ( ! construct.panel || '' === construct.panel() ) { 1284 transitionParentPane = true; 1285 } else if ( api.panel( construct.panel() ).contentContainer.hasClass( 'skip-transition' ) ) { 1286 transitionParentPane = true; 1287 } else { 1288 transitionParentPane = false; 1289 } 1290 if ( transitionParentPane ) { 1291 elements = elements.add( '#customize-info, .customize-pane-parent' ); 1292 } 1293 1294 // Handle `transitionEnd` event. 1295 transitionEndCallback = function( e ) { 1296 if ( 2 !== e.eventPhase || ! $( e.target ).is( content ) ) { 1297 return; 1298 } 1299 content.off( normalizedTransitionendEventName, transitionEndCallback ); 1300 elements.removeClass( 'busy' ); 1301 if ( completeCallback ) { 1302 completeCallback(); 1303 } 1304 }; 1305 content.on( normalizedTransitionendEventName, transitionEndCallback ); 1306 elements.addClass( 'busy' ); 1307 1308 // Prevent screen flicker when pane has been scrolled before expanding. 1309 _.defer( function() { 1310 var container = content.closest( '.wp-full-overlay-sidebar-content' ), 1311 currentScrollTop = container.scrollTop(), 1312 previousScrollTop = content.data( 'previous-scrollTop' ) || 0, 1313 expanded = construct.expanded(); 1314 1315 if ( expanded && 0 < currentScrollTop ) { 1316 content.css( 'top', currentScrollTop + 'px' ); 1317 content.data( 'previous-scrollTop', currentScrollTop ); 1318 } else if ( ! expanded && 0 < currentScrollTop + previousScrollTop ) { 1319 content.css( 'top', previousScrollTop - currentScrollTop + 'px' ); 1320 container.scrollTop( previousScrollTop ); 1321 } 1322 } ); 1323 }, 1324 1325 /* 1326 * is documented using @borrows in the constructor. 1327 */ 1328 focus: focus, 1329 1330 /** 1331 * Return the container html, generated from its JS template, if it exists. 1332 * 1333 * @since 4.3.0 1334 */ 1335 getContainer: function () { 1336 var template, 1337 container = this; 1338 1339 if ( 0 !== $( '#tmpl-' + container.templateSelector ).length ) { 1340 template = wp.template( container.templateSelector ); 1341 } else { 1342 template = wp.template( 'customize-' + container.containerType + '-default' ); 1343 } 1344 if ( template && container.container ) { 1345 return template( _.extend( 1346 { id: container.id }, 1347 container.params 1348 ) ).toString().trim(); 1349 } 1350 1351 return '<li></li>'; 1352 }, 1353 1354 /** 1355 * Find content element which is displayed when the section is expanded. 1356 * 1357 * After a construct is initialized, the return value will be available via the `contentContainer` property. 1358 * By default the element will be related it to the parent container with `aria-owns` and detached. 1359 * Custom panels and sections (such as the `NewMenuSection`) that do not have a sliding pane should 1360 * just return the content element without needing to add the `aria-owns` element or detach it from 1361 * the container. Such non-sliding pane custom sections also need to override the `onChangeExpanded` 1362 * method to handle animating the panel/section into and out of view. 1363 * 1364 * @since 4.7.0 1365 * @access public 1366 * 1367 * @return {jQuery} Detached content element. 1368 */ 1369 getContent: function() { 1370 var construct = this, 1371 container = construct.container, 1372 content = container.find( '.accordion-section-content, .control-panel-content' ).first(), 1373 contentId = 'sub-' + container.attr( 'id' ), 1374 ownedElements = contentId, 1375 alreadyOwnedElements = container.attr( 'aria-owns' ); 1376 1377 if ( alreadyOwnedElements ) { 1378 ownedElements = ownedElements + ' ' + alreadyOwnedElements; 1379 } 1380 container.attr( 'aria-owns', ownedElements ); 1381 1382 return content.detach().attr( { 1383 'id': contentId, 1384 'class': 'customize-pane-child ' + content.attr( 'class' ) + ' ' + container.attr( 'class' ) 1385 } ); 1386 } 1387 }); 1388 1389 api.Section = Container.extend(/** @lends wp.customize.Section.prototype */{ 1390 containerType: 'section', 1391 containerParent: '#customize-theme-controls', 1392 containerPaneParent: '.customize-pane-parent', 1393 defaults: { 1394 title: '', 1395 description: '', 1396 priority: 100, 1397 type: 'default', 1398 content: null, 1399 active: true, 1400 instanceNumber: null, 1401 panel: null, 1402 customizeAction: '' 1403 }, 1404 1405 /** 1406 * @constructs wp.customize.Section 1407 * @augments wp.customize~Container 1408 * 1409 * @since 4.1.0 1410 * 1411 * @param {string} id - The ID for the section. 1412 * @param {Object} options - Options. 1413 * @param {string} options.title - Title shown when section is collapsed and expanded. 1414 * @param {string} [options.description] - Description shown at the top of the section. 1415 * @param {number} [options.priority=100] - The sort priority for the section. 1416 * @param {string} [options.type=default] - The type of the section. See wp.customize.sectionConstructor. 1417 * @param {string} [options.content] - The markup to be used for the section container. If empty, a JS template is used. 1418 * @param {boolean} [options.active=true] - Whether the section is active or not. 1419 * @param {string} options.panel - The ID for the panel this section is associated with. 1420 * @param {string} [options.customizeAction] - Additional context information shown before the section title when expanded. 1421 * @param {Object} [options.params] - Deprecated wrapper for the above properties. 1422 */ 1423 initialize: function ( id, options ) { 1424 var section = this, params; 1425 params = options.params || options; 1426 1427 // Look up the type if one was not supplied. 1428 if ( ! params.type ) { 1429 _.find( api.sectionConstructor, function( Constructor, type ) { 1430 if ( Constructor === section.constructor ) { 1431 params.type = type; 1432 return true; 1433 } 1434 return false; 1435 } ); 1436 } 1437 1438 Container.prototype.initialize.call( section, id, params ); 1439 1440 section.id = id; 1441 section.panel = new api.Value(); 1442 section.panel.bind( function ( id ) { 1443 $( section.headContainer ).toggleClass( 'control-subsection', !! id ); 1444 }); 1445 section.panel.set( section.params.panel || '' ); 1446 api.utils.bubbleChildValueChanges( section, [ 'panel' ] ); 1447 1448 section.embed(); 1449 section.deferred.embedded.done( function () { 1450 section.ready(); 1451 }); 1452 }, 1453 1454 /** 1455 * Embed the container in the DOM when any parent panel is ready. 1456 * 1457 * @since 4.1.0 1458 */ 1459 embed: function () { 1460 var inject, 1461 section = this; 1462 1463 section.containerParent = api.ensure( section.containerParent ); 1464 1465 // Watch for changes to the panel state. 1466 inject = function ( panelId ) { 1467 var parentContainer; 1468 if ( panelId ) { 1469 // The panel has been supplied, so wait until the panel object is registered. 1470 api.panel( panelId, function ( panel ) { 1471 // The panel has been registered, wait for it to become ready/initialized. 1472 panel.deferred.embedded.done( function () { 1473 parentContainer = panel.contentContainer; 1474 if ( ! section.headContainer.parent().is( parentContainer ) ) { 1475 parentContainer.append( section.headContainer ); 1476 } 1477 if ( ! section.contentContainer.parent().is( section.headContainer ) ) { 1478 section.containerParent.append( section.contentContainer ); 1479 } 1480 section.deferred.embedded.resolve(); 1481 }); 1482 } ); 1483 } else { 1484 // There is no panel, so embed the section in the root of the customizer. 1485 parentContainer = api.ensure( section.containerPaneParent ); 1486 if ( ! section.headContainer.parent().is( parentContainer ) ) { 1487 parentContainer.append( section.headContainer ); 1488 } 1489 if ( ! section.contentContainer.parent().is( section.headContainer ) ) { 1490 section.containerParent.append( section.contentContainer ); 1491 } 1492 section.deferred.embedded.resolve(); 1493 } 1494 }; 1495 section.panel.bind( inject ); 1496 inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one. 1497 }, 1498 1499 /** 1500 * Add behaviors for the accordion section. 1501 * 1502 * @since 4.1.0 1503 */ 1504 attachEvents: function () { 1505 var meta, content, section = this; 1506 1507 if ( section.container.hasClass( 'cannot-expand' ) ) { 1508 return; 1509 } 1510 1511 // Expand/Collapse accordion sections on click. 1512 section.container.find( '.accordion-section-title, .customize-section-back' ).on( 'click keydown', function( event ) { 1513 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 1514 return; 1515 } 1516 event.preventDefault(); // Keep this AFTER the key filter above. 1517 1518 if ( section.expanded() ) { 1519 section.collapse(); 1520 } else { 1521 section.expand(); 1522 } 1523 }); 1524 1525 // This is very similar to what is found for api.Panel.attachEvents(). 1526 section.container.find( '.customize-section-title .customize-help-toggle' ).on( 'click', function() { 1527 1528 meta = section.container.find( '.section-meta' ); 1529 if ( meta.hasClass( 'cannot-expand' ) ) { 1530 return; 1531 } 1532 content = meta.find( '.customize-section-description:first' ); 1533 content.toggleClass( 'open' ); 1534 content.slideToggle( section.defaultExpandedArguments.duration, function() { 1535 content.trigger( 'toggled' ); 1536 } ); 1537 $( this ).attr( 'aria-expanded', function( i, attr ) { 1538 return 'true' === attr ? 'false' : 'true'; 1539 }); 1540 }); 1541 }, 1542 1543 /** 1544 * Return whether this section has any active controls. 1545 * 1546 * @since 4.1.0 1547 * 1548 * @return {boolean} 1549 */ 1550 isContextuallyActive: function () { 1551 var section = this, 1552 controls = section.controls(), 1553 activeCount = 0; 1554 _( controls ).each( function ( control ) { 1555 if ( control.active() ) { 1556 activeCount += 1; 1557 } 1558 } ); 1559 return ( activeCount !== 0 ); 1560 }, 1561 1562 /** 1563 * Get the controls that are associated with this section, sorted by their priority Value. 1564 * 1565 * @since 4.1.0 1566 * 1567 * @return {Array} 1568 */ 1569 controls: function () { 1570 return this._children( 'section', 'control' ); 1571 }, 1572 1573 /** 1574 * Update UI to reflect expanded state. 1575 * 1576 * @since 4.1.0 1577 * 1578 * @param {boolean} expanded 1579 * @param {Object} args 1580 */ 1581 onChangeExpanded: function ( expanded, args ) { 1582 var section = this, 1583 container = section.headContainer.closest( '.wp-full-overlay-sidebar-content' ), 1584 content = section.contentContainer, 1585 overlay = section.headContainer.closest( '.wp-full-overlay' ), 1586 backBtn = content.find( '.customize-section-back' ), 1587 sectionTitle = section.headContainer.find( '.accordion-section-title' ).first(), 1588 expand, panel; 1589 1590 if ( expanded && ! content.hasClass( 'open' ) ) { 1591 1592 if ( args.unchanged ) { 1593 expand = args.completeCallback; 1594 } else { 1595 expand = function() { 1596 section._animateChangeExpanded( function() { 1597 sectionTitle.attr( 'tabindex', '-1' ); 1598 backBtn.attr( 'tabindex', '0' ); 1599 1600 backBtn.trigger( 'focus' ); 1601 content.css( 'top', '' ); 1602 container.scrollTop( 0 ); 1603 1604 if ( args.completeCallback ) { 1605 args.completeCallback(); 1606 } 1607 } ); 1608 1609 content.addClass( 'open' ); 1610 overlay.addClass( 'section-open' ); 1611 api.state( 'expandedSection' ).set( section ); 1612 }.bind( this ); 1613 } 1614 1615 if ( ! args.allowMultiple ) { 1616 api.section.each( function ( otherSection ) { 1617 if ( otherSection !== section ) { 1618 otherSection.collapse( { duration: args.duration } ); 1619 } 1620 }); 1621 } 1622 1623 if ( section.panel() ) { 1624 api.panel( section.panel() ).expand({ 1625 duration: args.duration, 1626 completeCallback: expand 1627 }); 1628 } else { 1629 if ( ! args.allowMultiple ) { 1630 api.panel.each( function( panel ) { 1631 panel.collapse(); 1632 }); 1633 } 1634 expand(); 1635 } 1636 1637 } else if ( ! expanded && content.hasClass( 'open' ) ) { 1638 if ( section.panel() ) { 1639 panel = api.panel( section.panel() ); 1640 if ( panel.contentContainer.hasClass( 'skip-transition' ) ) { 1641 panel.collapse(); 1642 } 1643 } 1644 section._animateChangeExpanded( function() { 1645 backBtn.attr( 'tabindex', '-1' ); 1646 sectionTitle.attr( 'tabindex', '0' ); 1647 1648 sectionTitle.trigger( 'focus' ); 1649 content.css( 'top', '' ); 1650 1651 if ( args.completeCallback ) { 1652 args.completeCallback(); 1653 } 1654 } ); 1655 1656 content.removeClass( 'open' ); 1657 overlay.removeClass( 'section-open' ); 1658 if ( section === api.state( 'expandedSection' ).get() ) { 1659 api.state( 'expandedSection' ).set( false ); 1660 } 1661 1662 } else { 1663 if ( args.completeCallback ) { 1664 args.completeCallback(); 1665 } 1666 } 1667 } 1668 }); 1669 1670 api.ThemesSection = api.Section.extend(/** @lends wp.customize.ThemesSection.prototype */{ 1671 currentTheme: '', 1672 overlay: '', 1673 template: '', 1674 screenshotQueue: null, 1675 $window: null, 1676 $body: null, 1677 loaded: 0, 1678 loading: false, 1679 fullyLoaded: false, 1680 term: '', 1681 tags: '', 1682 nextTerm: '', 1683 nextTags: '', 1684 filtersHeight: 0, 1685 headerContainer: null, 1686 updateCountDebounced: null, 1687 1688 /** 1689 * wp.customize.ThemesSection 1690 * 1691 * Custom section for themes that loads themes by category, and also 1692 * handles the theme-details view rendering and navigation. 1693 * 1694 * @constructs wp.customize.ThemesSection 1695 * @augments wp.customize.Section 1696 * 1697 * @since 4.9.0 1698 * 1699 * @param {string} id - ID. 1700 * @param {Object} options - Options. 1701 * @return {void} 1702 */ 1703 initialize: function( id, options ) { 1704 var section = this; 1705 section.headerContainer = $(); 1706 section.$window = $( window ); 1707 section.$body = $( document.body ); 1708 api.Section.prototype.initialize.call( section, id, options ); 1709 section.updateCountDebounced = _.debounce( section.updateCount, 500 ); 1710 }, 1711 1712 /** 1713 * Embed the section in the DOM when the themes panel is ready. 1714 * 1715 * Insert the section before the themes container. Assume that a themes section is within a panel, but not necessarily the themes panel. 1716 * 1717 * @since 4.9.0 1718 */ 1719 embed: function() { 1720 var inject, 1721 section = this; 1722 1723 // Watch for changes to the panel state. 1724 inject = function( panelId ) { 1725 var parentContainer; 1726 api.panel( panelId, function( panel ) { 1727 1728 // The panel has been registered, wait for it to become ready/initialized. 1729 panel.deferred.embedded.done( function() { 1730 parentContainer = panel.contentContainer; 1731 if ( ! section.headContainer.parent().is( parentContainer ) ) { 1732 parentContainer.find( '.customize-themes-full-container-container' ).before( section.headContainer ); 1733 } 1734 if ( ! section.contentContainer.parent().is( section.headContainer ) ) { 1735 section.containerParent.append( section.contentContainer ); 1736 } 1737 section.deferred.embedded.resolve(); 1738 }); 1739 } ); 1740 }; 1741 section.panel.bind( inject ); 1742 inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one. 1743 }, 1744 1745 /** 1746 * Set up. 1747 * 1748 * @since 4.2.0 1749 * 1750 * @return {void} 1751 */ 1752 ready: function() { 1753 var section = this; 1754 section.overlay = section.container.find( '.theme-overlay' ); 1755 section.template = wp.template( 'customize-themes-details-view' ); 1756 1757 // Bind global keyboard events. 1758 section.container.on( 'keydown', function( event ) { 1759 if ( ! section.overlay.find( '.theme-wrap' ).is( ':visible' ) ) { 1760 return; 1761 } 1762 1763 // Pressing the right arrow key fires a theme:next event. 1764 if ( 39 === event.keyCode ) { 1765 section.nextTheme(); 1766 } 1767 1768 // Pressing the left arrow key fires a theme:previous event. 1769 if ( 37 === event.keyCode ) { 1770 section.previousTheme(); 1771 } 1772 1773 // Pressing the escape key fires a theme:collapse event. 1774 if ( 27 === event.keyCode ) { 1775 if ( section.$body.hasClass( 'modal-open' ) ) { 1776 1777 // Escape from the details modal. 1778 section.closeDetails(); 1779 } else { 1780 1781 // Escape from the inifinite scroll list. 1782 section.headerContainer.find( '.customize-themes-section-title' ).focus(); 1783 } 1784 event.stopPropagation(); // Prevent section from being collapsed. 1785 } 1786 }); 1787 1788 section.renderScreenshots = _.throttle( section.renderScreenshots, 100 ); 1789 1790 _.bindAll( section, 'renderScreenshots', 'loadMore', 'checkTerm', 'filtersChecked' ); 1791 }, 1792 1793 /** 1794 * Override Section.isContextuallyActive method. 1795 * 1796 * Ignore the active states' of the contained theme controls, and just 1797 * use the section's own active state instead. This prevents empty search 1798 * results for theme sections from causing the section to become inactive. 1799 * 1800 * @since 4.2.0 1801 * 1802 * @return {boolean} 1803 */ 1804 isContextuallyActive: function () { 1805 return this.active(); 1806 }, 1807 1808 /** 1809 * Attach events. 1810 * 1811 * @since 4.2.0 1812 * 1813 * @return {void} 1814 */ 1815 attachEvents: function () { 1816 var section = this, debounced; 1817 1818 // Expand/Collapse accordion sections on click. 1819 section.container.find( '.customize-section-back' ).on( 'click keydown', function( event ) { 1820 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 1821 return; 1822 } 1823 event.preventDefault(); // Keep this AFTER the key filter above. 1824 section.collapse(); 1825 }); 1826 1827 section.headerContainer = $( '#accordion-section-' + section.id ); 1828 1829 // Expand section/panel. Only collapse when opening another section. 1830 section.headerContainer.on( 'click', '.customize-themes-section-title', function() { 1831 1832 // Toggle accordion filters under section headers. 1833 if ( section.headerContainer.find( '.filter-details' ).length ) { 1834 section.headerContainer.find( '.customize-themes-section-title' ) 1835 .toggleClass( 'details-open' ) 1836 .attr( 'aria-expanded', function( i, attr ) { 1837 return 'true' === attr ? 'false' : 'true'; 1838 }); 1839 section.headerContainer.find( '.filter-details' ).slideToggle( 180 ); 1840 } 1841 1842 // Open the section. 1843 if ( ! section.expanded() ) { 1844 section.expand(); 1845 } 1846 }); 1847 1848 // Preview installed themes. 1849 section.container.on( 'click', '.theme-actions .preview-theme', function() { 1850 api.panel( 'themes' ).loadThemePreview( $( this ).data( 'slug' ) ); 1851 }); 1852 1853 // Theme navigation in details view. 1854 section.container.on( 'click', '.left', function() { 1855 section.previousTheme(); 1856 }); 1857 1858 section.container.on( 'click', '.right', function() { 1859 section.nextTheme(); 1860 }); 1861 1862 section.container.on( 'click', '.theme-backdrop, .close', function() { 1863 section.closeDetails(); 1864 }); 1865 1866 if ( 'local' === section.params.filter_type ) { 1867 1868 // Filter-search all theme objects loaded in the section. 1869 section.container.on( 'input', '.wp-filter-search-themes', function( event ) { 1870 section.filterSearch( event.currentTarget.value ); 1871 }); 1872 1873 } else if ( 'remote' === section.params.filter_type ) { 1874 1875 // Event listeners for remote queries with user-entered terms. 1876 // Search terms. 1877 debounced = _.debounce( section.checkTerm, 500 ); // Wait until there is no input for 500 milliseconds to initiate a search. 1878 section.contentContainer.on( 'input', '.wp-filter-search', function() { 1879 if ( ! api.panel( 'themes' ).expanded() ) { 1880 return; 1881 } 1882 debounced( section ); 1883 if ( ! section.expanded() ) { 1884 section.expand(); 1885 } 1886 }); 1887 1888 // Feature filters. 1889 section.contentContainer.on( 'click', '.filter-group input', function() { 1890 section.filtersChecked(); 1891 section.checkTerm( section ); 1892 }); 1893 } 1894 1895 // Toggle feature filters. 1896 section.contentContainer.on( 'click', '.feature-filter-toggle', function( e ) { 1897 var $themeContainer = $( '.customize-themes-full-container' ), 1898 $filterToggle = $( e.currentTarget ); 1899 section.filtersHeight = $filterToggle.parent().next( '.filter-drawer' ).height(); 1900 1901 if ( 0 < $themeContainer.scrollTop() ) { 1902 $themeContainer.animate( { scrollTop: 0 }, 400 ); 1903 1904 if ( $filterToggle.hasClass( 'open' ) ) { 1905 return; 1906 } 1907 } 1908 1909 $filterToggle 1910 .toggleClass( 'open' ) 1911 .attr( 'aria-expanded', function( i, attr ) { 1912 return 'true' === attr ? 'false' : 'true'; 1913 }) 1914 .parent().next( '.filter-drawer' ).slideToggle( 180, 'linear' ); 1915 1916 if ( $filterToggle.hasClass( 'open' ) ) { 1917 var marginOffset = 1018 < window.innerWidth ? 50 : 76; 1918 1919 section.contentContainer.find( '.themes' ).css( 'margin-top', section.filtersHeight + marginOffset ); 1920 } else { 1921 section.contentContainer.find( '.themes' ).css( 'margin-top', 0 ); 1922 } 1923 }); 1924 1925 // Setup section cross-linking. 1926 section.contentContainer.on( 'click', '.no-themes-local .search-dotorg-themes', function() { 1927 api.section( 'wporg_themes' ).focus(); 1928 }); 1929 1930 function updateSelectedState() { 1931 var el = section.headerContainer.find( '.customize-themes-section-title' ); 1932 el.toggleClass( 'selected', section.expanded() ); 1933 el.attr( 'aria-expanded', section.expanded() ? 'true' : 'false' ); 1934 if ( ! section.expanded() ) { 1935 el.removeClass( 'details-open' ); 1936 } 1937 } 1938 section.expanded.bind( updateSelectedState ); 1939 updateSelectedState(); 1940 1941 // Move section controls to the themes area. 1942 api.bind( 'ready', function () { 1943 section.contentContainer = section.container.find( '.customize-themes-section' ); 1944 section.contentContainer.appendTo( $( '.customize-themes-full-container' ) ); 1945 section.container.add( section.headerContainer ); 1946 }); 1947 }, 1948 1949 /** 1950 * Update UI to reflect expanded state 1951 * 1952 * @since 4.2.0 1953 * 1954 * @param {boolean} expanded 1955 * @param {Object} args 1956 * @param {boolean} args.unchanged 1957 * @param {Function} args.completeCallback 1958 * @return {void} 1959 */ 1960 onChangeExpanded: function ( expanded, args ) { 1961 1962 // Note: there is a second argument 'args' passed. 1963 var section = this, 1964 container = section.contentContainer.closest( '.customize-themes-full-container' ); 1965 1966 // Immediately call the complete callback if there were no changes. 1967 if ( args.unchanged ) { 1968 if ( args.completeCallback ) { 1969 args.completeCallback(); 1970 } 1971 return; 1972 } 1973 1974 function expand() { 1975 1976 // Try to load controls if none are loaded yet. 1977 if ( 0 === section.loaded ) { 1978 section.loadThemes(); 1979 } 1980 1981 // Collapse any sibling sections/panels. 1982 api.section.each( function ( otherSection ) { 1983 var searchTerm; 1984 1985 if ( otherSection !== section ) { 1986 1987 // Try to sync the current search term to the new section. 1988 if ( 'themes' === otherSection.params.type ) { 1989 searchTerm = otherSection.contentContainer.find( '.wp-filter-search' ).val(); 1990 section.contentContainer.find( '.wp-filter-search' ).val( searchTerm ); 1991 1992 // Directly initialize an empty remote search to avoid a race condition. 1993 if ( '' === searchTerm && '' !== section.term && 'local' !== section.params.filter_type ) { 1994 section.term = ''; 1995 section.initializeNewQuery( section.term, section.tags ); 1996 } else { 1997 if ( 'remote' === section.params.filter_type ) { 1998 section.checkTerm( section ); 1999 } else if ( 'local' === section.params.filter_type ) { 2000 section.filterSearch( searchTerm ); 2001 } 2002 } 2003 otherSection.collapse( { duration: args.duration } ); 2004 } 2005 } 2006 }); 2007 2008 section.contentContainer.addClass( 'current-section' ); 2009 container.scrollTop(); 2010 2011 container.on( 'scroll', _.throttle( section.renderScreenshots, 300 ) ); 2012 container.on( 'scroll', _.throttle( section.loadMore, 300 ) ); 2013 2014 if ( args.completeCallback ) { 2015 args.completeCallback(); 2016 } 2017 section.updateCount(); // Show this section's count. 2018 } 2019 2020 if ( expanded ) { 2021 if ( section.panel() && api.panel.has( section.panel() ) ) { 2022 api.panel( section.panel() ).expand({ 2023 duration: args.duration, 2024 completeCallback: expand 2025 }); 2026 } else { 2027 expand(); 2028 } 2029 } else { 2030 section.contentContainer.removeClass( 'current-section' ); 2031 2032 // Always hide, even if they don't exist or are already hidden. 2033 section.headerContainer.find( '.filter-details' ).slideUp( 180 ); 2034 2035 container.off( 'scroll' ); 2036 2037 if ( args.completeCallback ) { 2038 args.completeCallback(); 2039 } 2040 } 2041 }, 2042 2043 /** 2044 * Return the section's content element without detaching from the parent. 2045 * 2046 * @since 4.9.0 2047 * 2048 * @return {jQuery} 2049 */ 2050 getContent: function() { 2051 return this.container.find( '.control-section-content' ); 2052 }, 2053 2054 /** 2055 * Load theme data via Ajax and add themes to the section as controls. 2056 * 2057 * @since 4.9.0 2058 * 2059 * @return {void} 2060 */ 2061 loadThemes: function() { 2062 var section = this, params, page, request; 2063 2064 if ( section.loading ) { 2065 return; // We're already loading a batch of themes. 2066 } 2067 2068 // Parameters for every API query. Additional params are set in PHP. 2069 page = Math.ceil( section.loaded / 100 ) + 1; 2070 params = { 2071 'nonce': api.settings.nonce.switch_themes, 2072 'wp_customize': 'on', 2073 'theme_action': section.params.action, 2074 'customized_theme': api.settings.theme.stylesheet, 2075 'page': page 2076 }; 2077 2078 // Add fields for remote filtering. 2079 if ( 'remote' === section.params.filter_type ) { 2080 params.search = section.term; 2081 params.tags = section.tags; 2082 } 2083 2084 // Load themes. 2085 section.headContainer.closest( '.wp-full-overlay' ).addClass( 'loading' ); 2086 section.loading = true; 2087 section.container.find( '.no-themes' ).hide(); 2088 request = wp.ajax.post( 'customize_load_themes', params ); 2089 request.done(function( data ) { 2090 var themes = data.themes; 2091 2092 // Stop and try again if the term changed while loading. 2093 if ( '' !== section.nextTerm || '' !== section.nextTags ) { 2094 if ( section.nextTerm ) { 2095 section.term = section.nextTerm; 2096 } 2097 if ( section.nextTags ) { 2098 section.tags = section.nextTags; 2099 } 2100 section.nextTerm = ''; 2101 section.nextTags = ''; 2102 section.loading = false; 2103 section.loadThemes(); 2104 return; 2105 } 2106 2107 if ( 0 !== themes.length ) { 2108 2109 section.loadControls( themes, page ); 2110 2111 if ( 1 === page ) { 2112 2113 // Pre-load the first 3 theme screenshots. 2114 _.each( section.controls().slice( 0, 3 ), function( control ) { 2115 var img, src = control.params.theme.screenshot[0]; 2116 if ( src ) { 2117 img = new Image(); 2118 img.src = src; 2119 } 2120 }); 2121 if ( 'local' !== section.params.filter_type ) { 2122 wp.a11y.speak( api.settings.l10n.themeSearchResults.replace( '%d', data.info.results ) ); 2123 } 2124 } 2125 2126 _.delay( section.renderScreenshots, 100 ); // Wait for the controls to become visible. 2127 2128 if ( 'local' === section.params.filter_type || 100 > themes.length ) { 2129 // If we have less than the requested 100 themes, it's the end of the list. 2130 section.fullyLoaded = true; 2131 } 2132 } else { 2133 if ( 0 === section.loaded ) { 2134 section.container.find( '.no-themes' ).show(); 2135 wp.a11y.speak( section.container.find( '.no-themes' ).text() ); 2136 } else { 2137 section.fullyLoaded = true; 2138 } 2139 } 2140 if ( 'local' === section.params.filter_type ) { 2141 section.updateCount(); // Count of visible theme controls. 2142 } else { 2143 section.updateCount( data.info.results ); // Total number of results including pages not yet loaded. 2144 } 2145 section.container.find( '.unexpected-error' ).hide(); // Hide error notice in case it was previously shown. 2146 2147 // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case. 2148 section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' ); 2149 section.loading = false; 2150 }); 2151 request.fail(function( data ) { 2152 if ( 'undefined' === typeof data ) { 2153 section.container.find( '.unexpected-error' ).show(); 2154 wp.a11y.speak( section.container.find( '.unexpected-error' ).text() ); 2155 } else if ( 'undefined' !== typeof console && console.error ) { 2156 console.error( data ); 2157 } 2158 2159 // This cannot run on request.always, as section.loading may turn false before the new controls load in the success case. 2160 section.headContainer.closest( '.wp-full-overlay' ).removeClass( 'loading' ); 2161 section.loading = false; 2162 }); 2163 }, 2164 2165 /** 2166 * Loads controls into the section from data received from loadThemes(). 2167 * 2168 * @since 4.9.0 2169 * @param {Array} themes - Array of theme data to create controls with. 2170 * @param {number} page - Page of results being loaded. 2171 * @return {void} 2172 */ 2173 loadControls: function( themes, page ) { 2174 var newThemeControls = [], 2175 section = this; 2176 2177 // Add controls for each theme. 2178 _.each( themes, function( theme ) { 2179 var themeControl = new api.controlConstructor.theme( section.params.action + '_theme_' + theme.id, { 2180 type: 'theme', 2181 section: section.params.id, 2182 theme: theme, 2183 priority: section.loaded + 1 2184 } ); 2185 2186 api.control.add( themeControl ); 2187 newThemeControls.push( themeControl ); 2188 section.loaded = section.loaded + 1; 2189 }); 2190 2191 if ( 1 !== page ) { 2192 Array.prototype.push.apply( section.screenshotQueue, newThemeControls ); // Add new themes to the screenshot queue. 2193 } 2194 }, 2195 2196 /** 2197 * Determines whether more themes should be loaded, and loads them. 2198 * 2199 * @since 4.9.0 2200 * @return {void} 2201 */ 2202 loadMore: function() { 2203 var section = this, container, bottom, threshold; 2204 if ( ! section.fullyLoaded && ! section.loading ) { 2205 container = section.container.closest( '.customize-themes-full-container' ); 2206 2207 bottom = container.scrollTop() + container.height(); 2208 // Use a fixed distance to the bottom of loaded results to avoid unnecessarily 2209 // loading results sooner when using a percentage of scroll distance. 2210 threshold = container.prop( 'scrollHeight' ) - 3000; 2211 2212 if ( bottom > threshold ) { 2213 section.loadThemes(); 2214 } 2215 } 2216 }, 2217 2218 /** 2219 * Event handler for search input that filters visible controls. 2220 * 2221 * @since 4.9.0 2222 * 2223 * @param {string} term - The raw search input value. 2224 * @return {void} 2225 */ 2226 filterSearch: function( term ) { 2227 var count = 0, 2228 visible = false, 2229 section = this, 2230 noFilter = ( api.section.has( 'wporg_themes' ) && 'remote' !== section.params.filter_type ) ? '.no-themes-local' : '.no-themes', 2231 controls = section.controls(), 2232 terms; 2233 2234 if ( section.loading ) { 2235 return; 2236 } 2237 2238 // Standardize search term format and split into an array of individual words. 2239 terms = term.toLowerCase().trim().replace( /-/g, ' ' ).split( ' ' ); 2240 2241 _.each( controls, function( control ) { 2242 visible = control.filter( terms ); // Shows/hides and sorts control based on the applicability of the search term. 2243 if ( visible ) { 2244 count = count + 1; 2245 } 2246 }); 2247 2248 if ( 0 === count ) { 2249 section.container.find( noFilter ).show(); 2250 wp.a11y.speak( section.container.find( noFilter ).text() ); 2251 } else { 2252 section.container.find( noFilter ).hide(); 2253 } 2254 2255 section.renderScreenshots(); 2256 api.reflowPaneContents(); 2257 2258 // Update theme count. 2259 section.updateCountDebounced( count ); 2260 }, 2261 2262 /** 2263 * Event handler for search input that determines if the terms have changed and loads new controls as needed. 2264 * 2265 * @since 4.9.0 2266 * 2267 * @param {wp.customize.ThemesSection} section - The current theme section, passed through the debouncer. 2268 * @return {void} 2269 */ 2270 checkTerm: function( section ) { 2271 var newTerm; 2272 if ( 'remote' === section.params.filter_type ) { 2273 newTerm = section.contentContainer.find( '.wp-filter-search' ).val(); 2274 if ( section.term !== newTerm.trim() ) { 2275 section.initializeNewQuery( newTerm, section.tags ); 2276 } 2277 } 2278 }, 2279 2280 /** 2281 * Check for filters checked in the feature filter list and initialize a new query. 2282 * 2283 * @since 4.9.0 2284 * 2285 * @return {void} 2286 */ 2287 filtersChecked: function() { 2288 var section = this, 2289 items = section.container.find( '.filter-group' ).find( ':checkbox' ), 2290 tags = []; 2291 2292 _.each( items.filter( ':checked' ), function( item ) { 2293 tags.push( $( item ).prop( 'value' ) ); 2294 }); 2295 2296 // When no filters are checked, restore initial state. Update filter count. 2297 if ( 0 === tags.length ) { 2298 tags = ''; 2299 section.contentContainer.find( '.feature-filter-toggle .filter-count-0' ).show(); 2300 section.contentContainer.find( '.feature-filter-toggle .filter-count-filters' ).hide(); 2301 } else { 2302 section.contentContainer.find( '.feature-filter-toggle .theme-filter-count' ).text( tags.length ); 2303 section.contentContainer.find( '.feature-filter-toggle .filter-count-0' ).hide(); 2304 section.contentContainer.find( '.feature-filter-toggle .filter-count-filters' ).show(); 2305 } 2306 2307 // Check whether tags have changed, and either load or queue them. 2308 if ( ! _.isEqual( section.tags, tags ) ) { 2309 if ( section.loading ) { 2310 section.nextTags = tags; 2311 } else { 2312 if ( 'remote' === section.params.filter_type ) { 2313 section.initializeNewQuery( section.term, tags ); 2314 } else if ( 'local' === section.params.filter_type ) { 2315 section.filterSearch( tags.join( ' ' ) ); 2316 } 2317 } 2318 } 2319 }, 2320 2321 /** 2322 * Reset the current query and load new results. 2323 * 2324 * @since 4.9.0 2325 * 2326 * @param {string} newTerm - New term. 2327 * @param {Array} newTags - New tags. 2328 * @return {void} 2329 */ 2330 initializeNewQuery: function( newTerm, newTags ) { 2331 var section = this; 2332 2333 // Clear the controls in the section. 2334 _.each( section.controls(), function( control ) { 2335 control.container.remove(); 2336 api.control.remove( control.id ); 2337 }); 2338 section.loaded = 0; 2339 section.fullyLoaded = false; 2340 section.screenshotQueue = null; 2341 2342 // Run a new query, with loadThemes handling paging, etc. 2343 if ( ! section.loading ) { 2344 section.term = newTerm; 2345 section.tags = newTags; 2346 section.loadThemes(); 2347 } else { 2348 section.nextTerm = newTerm; // This will reload from loadThemes() with the newest term once the current batch is loaded. 2349 section.nextTags = newTags; // This will reload from loadThemes() with the newest tags once the current batch is loaded. 2350 } 2351 if ( ! section.expanded() ) { 2352 section.expand(); // Expand the section if it isn't expanded. 2353 } 2354 }, 2355 2356 /** 2357 * Render control's screenshot if the control comes into view. 2358 * 2359 * @since 4.2.0 2360 * 2361 * @return {void} 2362 */ 2363 renderScreenshots: function() { 2364 var section = this; 2365 2366 // Fill queue initially, or check for more if empty. 2367 if ( null === section.screenshotQueue || 0 === section.screenshotQueue.length ) { 2368 2369 // Add controls that haven't had their screenshots rendered. 2370 section.screenshotQueue = _.filter( section.controls(), function( control ) { 2371 return ! control.screenshotRendered; 2372 }); 2373 } 2374 2375 // Are all screenshots rendered (for now)? 2376 if ( ! section.screenshotQueue.length ) { 2377 return; 2378 } 2379 2380 section.screenshotQueue = _.filter( section.screenshotQueue, function( control ) { 2381 var $imageWrapper = control.container.find( '.theme-screenshot' ), 2382 $image = $imageWrapper.find( 'img' ); 2383 2384 if ( ! $image.length ) { 2385 return false; 2386 } 2387 2388 if ( $image.is( ':hidden' ) ) { 2389 return true; 2390 } 2391 2392 // Based on unveil.js. 2393 var wt = section.$window.scrollTop(), 2394 wb = wt + section.$window.height(), 2395 et = $image.offset().top, 2396 ih = $imageWrapper.height(), 2397 eb = et + ih, 2398 threshold = ih * 3, 2399 inView = eb >= wt - threshold && et <= wb + threshold; 2400 2401 if ( inView ) { 2402 control.container.trigger( 'render-screenshot' ); 2403 } 2404 2405 // If the image is in view return false so it's cleared from the queue. 2406 return ! inView; 2407 } ); 2408 }, 2409 2410 /** 2411 * Get visible count. 2412 * 2413 * @since 4.9.0 2414 * 2415 * @return {number} Visible count. 2416 */ 2417 getVisibleCount: function() { 2418 return this.contentContainer.find( 'li.customize-control:visible' ).length; 2419 }, 2420 2421 /** 2422 * Update the number of themes in the section. 2423 * 2424 * @since 4.9.0 2425 * 2426 * @return {void} 2427 */ 2428 updateCount: function( count ) { 2429 var section = this, countEl, displayed; 2430 2431 if ( ! count && 0 !== count ) { 2432 count = section.getVisibleCount(); 2433 } 2434 2435 displayed = section.contentContainer.find( '.themes-displayed' ); 2436 countEl = section.contentContainer.find( '.theme-count' ); 2437 2438 if ( 0 === count ) { 2439 countEl.text( '0' ); 2440 } else { 2441 2442 // Animate the count change for emphasis. 2443 displayed.fadeOut( 180, function() { 2444 countEl.text( count ); 2445 displayed.fadeIn( 180 ); 2446 } ); 2447 wp.a11y.speak( api.settings.l10n.announceThemeCount.replace( '%d', count ) ); 2448 } 2449 }, 2450 2451 /** 2452 * Advance the modal to the next theme. 2453 * 2454 * @since 4.2.0 2455 * 2456 * @return {void} 2457 */ 2458 nextTheme: function () { 2459 var section = this; 2460 if ( section.getNextTheme() ) { 2461 section.showDetails( section.getNextTheme(), function() { 2462 section.overlay.find( '.right' ).focus(); 2463 } ); 2464 } 2465 }, 2466 2467 /** 2468 * Get the next theme model. 2469 * 2470 * @since 4.2.0 2471 * 2472 * @return {wp.customize.ThemeControl|boolean} Next theme. 2473 */ 2474 getNextTheme: function () { 2475 var section = this, control, nextControl, sectionControls, i; 2476 control = api.control( section.params.action + '_theme_' + section.currentTheme ); 2477 sectionControls = section.controls(); 2478 i = _.indexOf( sectionControls, control ); 2479 if ( -1 === i ) { 2480 return false; 2481 } 2482 2483 nextControl = sectionControls[ i + 1 ]; 2484 if ( ! nextControl ) { 2485 return false; 2486 } 2487 return nextControl.params.theme; 2488 }, 2489 2490 /** 2491 * Advance the modal to the previous theme. 2492 * 2493 * @since 4.2.0 2494 * @return {void} 2495 */ 2496 previousTheme: function () { 2497 var section = this; 2498 if ( section.getPreviousTheme() ) { 2499 section.showDetails( section.getPreviousTheme(), function() { 2500 section.overlay.find( '.left' ).focus(); 2501 } ); 2502 } 2503 }, 2504 2505 /** 2506 * Get the previous theme model. 2507 * 2508 * @since 4.2.0 2509 * @return {wp.customize.ThemeControl|boolean} Previous theme. 2510 */ 2511 getPreviousTheme: function () { 2512 var section = this, control, nextControl, sectionControls, i; 2513 control = api.control( section.params.action + '_theme_' + section.currentTheme ); 2514 sectionControls = section.controls(); 2515 i = _.indexOf( sectionControls, control ); 2516 if ( -1 === i ) { 2517 return false; 2518 } 2519 2520 nextControl = sectionControls[ i - 1 ]; 2521 if ( ! nextControl ) { 2522 return false; 2523 } 2524 return nextControl.params.theme; 2525 }, 2526 2527 /** 2528 * Disable buttons when we're viewing the first or last theme. 2529 * 2530 * @since 4.2.0 2531 * 2532 * @return {void} 2533 */ 2534 updateLimits: function () { 2535 if ( ! this.getNextTheme() ) { 2536 this.overlay.find( '.right' ).addClass( 'disabled' ); 2537 } 2538 if ( ! this.getPreviousTheme() ) { 2539 this.overlay.find( '.left' ).addClass( 'disabled' ); 2540 } 2541 }, 2542 2543 /** 2544 * Load theme preview. 2545 * 2546 * @since 4.7.0 2547 * @access public 2548 * 2549 * @deprecated 2550 * @param {string} themeId Theme ID. 2551 * @return {jQuery.promise} Promise. 2552 */ 2553 loadThemePreview: function( themeId ) { 2554 return api.ThemesPanel.prototype.loadThemePreview.call( this, themeId ); 2555 }, 2556 2557 /** 2558 * Render & show the theme details for a given theme model. 2559 * 2560 * @since 4.2.0 2561 * 2562 * @param {Object} theme - Theme. 2563 * @param {Function} [callback] - Callback once the details have been shown. 2564 * @return {void} 2565 */ 2566 showDetails: function ( theme, callback ) { 2567 var section = this, panel = api.panel( 'themes' ); 2568 section.currentTheme = theme.id; 2569 section.overlay.html( section.template( theme ) ) 2570 .fadeIn( 'fast' ) 2571 .focus(); 2572 2573 function disableSwitchButtons() { 2574 return ! panel.canSwitchTheme( theme.id ); 2575 } 2576 2577 // Temporary special function since supplying SFTP credentials does not work yet. See #42184. 2578 function disableInstallButtons() { 2579 return disableSwitchButtons() || false === api.settings.theme._canInstall || true === api.settings.theme._filesystemCredentialsNeeded; 2580 } 2581 2582 section.overlay.find( 'button.preview, button.preview-theme' ).toggleClass( 'disabled', disableSwitchButtons() ); 2583 section.overlay.find( 'button.theme-install' ).toggleClass( 'disabled', disableInstallButtons() ); 2584 2585 section.$body.addClass( 'modal-open' ); 2586 section.containFocus( section.overlay ); 2587 section.updateLimits(); 2588 wp.a11y.speak( api.settings.l10n.announceThemeDetails.replace( '%s', theme.name ) ); 2589 if ( callback ) { 2590 callback(); 2591 } 2592 }, 2593 2594 /** 2595 * Close the theme details modal. 2596 * 2597 * @since 4.2.0 2598 * 2599 * @return {void} 2600 */ 2601 closeDetails: function () { 2602 var section = this; 2603 section.$body.removeClass( 'modal-open' ); 2604 section.overlay.fadeOut( 'fast' ); 2605 api.control( section.params.action + '_theme_' + section.currentTheme ).container.find( '.theme' ).focus(); 2606 }, 2607 2608 /** 2609 * Keep tab focus within the theme details modal. 2610 * 2611 * @since 4.2.0 2612 * 2613 * @param {jQuery} el - Element to contain focus. 2614 * @return {void} 2615 */ 2616 containFocus: function( el ) { 2617 var tabbables; 2618 2619 el.on( 'keydown', function( event ) { 2620 2621 // Return if it's not the tab key 2622 // When navigating with prev/next focus is already handled. 2623 if ( 9 !== event.keyCode ) { 2624 return; 2625 } 2626 2627 // Uses jQuery UI to get the tabbable elements. 2628 tabbables = $( ':tabbable', el ); 2629 2630 // Keep focus within the overlay. 2631 if ( tabbables.last()[0] === event.target && ! event.shiftKey ) { 2632 tabbables.first().focus(); 2633 return false; 2634 } else if ( tabbables.first()[0] === event.target && event.shiftKey ) { 2635 tabbables.last().focus(); 2636 return false; 2637 } 2638 }); 2639 } 2640 }); 2641 2642 api.OuterSection = api.Section.extend(/** @lends wp.customize.OuterSection.prototype */{ 2643 2644 /** 2645 * Class wp.customize.OuterSection. 2646 * 2647 * Creates section outside of the sidebar, there is no ui to trigger collapse/expand so 2648 * it would require custom handling. 2649 * 2650 * @constructs wp.customize.OuterSection 2651 * @augments wp.customize.Section 2652 * 2653 * @since 4.9.0 2654 * 2655 * @return {void} 2656 */ 2657 initialize: function() { 2658 var section = this; 2659 section.containerParent = '#customize-outer-theme-controls'; 2660 section.containerPaneParent = '.customize-outer-pane-parent'; 2661 api.Section.prototype.initialize.apply( section, arguments ); 2662 }, 2663 2664 /** 2665 * Overrides api.Section.prototype.onChangeExpanded to prevent collapse/expand effect 2666 * on other sections and panels. 2667 * 2668 * @since 4.9.0 2669 * 2670 * @param {boolean} expanded - The expanded state to transition to. 2671 * @param {Object} [args] - Args. 2672 * @param {boolean} [args.unchanged] - Whether the state is already known to not be changed, and so short-circuit with calling completeCallback early. 2673 * @param {Function} [args.completeCallback] - Function to call when the slideUp/slideDown has completed. 2674 * @param {Object} [args.duration] - The duration for the animation. 2675 */ 2676 onChangeExpanded: function( expanded, args ) { 2677 var section = this, 2678 container = section.headContainer.closest( '.wp-full-overlay-sidebar-content' ), 2679 content = section.contentContainer, 2680 backBtn = content.find( '.customize-section-back' ), 2681 sectionTitle = section.headContainer.find( '.accordion-section-title' ).first(), 2682 body = $( document.body ), 2683 expand, panel; 2684 2685 body.toggleClass( 'outer-section-open', expanded ); 2686 section.container.toggleClass( 'open', expanded ); 2687 section.container.removeClass( 'busy' ); 2688 api.section.each( function( _section ) { 2689 if ( 'outer' === _section.params.type && _section.id !== section.id ) { 2690 _section.container.removeClass( 'open' ); 2691 } 2692 } ); 2693 2694 if ( expanded && ! content.hasClass( 'open' ) ) { 2695 2696 if ( args.unchanged ) { 2697 expand = args.completeCallback; 2698 } else { 2699 expand = function() { 2700 section._animateChangeExpanded( function() { 2701 sectionTitle.attr( 'tabindex', '-1' ); 2702 backBtn.attr( 'tabindex', '0' ); 2703 2704 backBtn.trigger( 'focus' ); 2705 content.css( 'top', '' ); 2706 container.scrollTop( 0 ); 2707 2708 if ( args.completeCallback ) { 2709 args.completeCallback(); 2710 } 2711 } ); 2712 2713 content.addClass( 'open' ); 2714 }.bind( this ); 2715 } 2716 2717 if ( section.panel() ) { 2718 api.panel( section.panel() ).expand({ 2719 duration: args.duration, 2720 completeCallback: expand 2721 }); 2722 } else { 2723 expand(); 2724 } 2725 2726 } else if ( ! expanded && content.hasClass( 'open' ) ) { 2727 if ( section.panel() ) { 2728 panel = api.panel( section.panel() ); 2729 if ( panel.contentContainer.hasClass( 'skip-transition' ) ) { 2730 panel.collapse(); 2731 } 2732 } 2733 section._animateChangeExpanded( function() { 2734 backBtn.attr( 'tabindex', '-1' ); 2735 sectionTitle.attr( 'tabindex', '0' ); 2736 2737 sectionTitle.trigger( 'focus' ); 2738 content.css( 'top', '' ); 2739 2740 if ( args.completeCallback ) { 2741 args.completeCallback(); 2742 } 2743 } ); 2744 2745 content.removeClass( 'open' ); 2746 2747 } else { 2748 if ( args.completeCallback ) { 2749 args.completeCallback(); 2750 } 2751 } 2752 } 2753 }); 2754 2755 api.Panel = Container.extend(/** @lends wp.customize.Panel.prototype */{ 2756 containerType: 'panel', 2757 2758 /** 2759 * @constructs wp.customize.Panel 2760 * @augments wp.customize~Container 2761 * 2762 * @since 4.1.0 2763 * 2764 * @param {string} id - The ID for the panel. 2765 * @param {Object} options - Object containing one property: params. 2766 * @param {string} options.title - Title shown when panel is collapsed and expanded. 2767 * @param {string} [options.description] - Description shown at the top of the panel. 2768 * @param {number} [options.priority=100] - The sort priority for the panel. 2769 * @param {string} [options.type=default] - The type of the panel. See wp.customize.panelConstructor. 2770 * @param {string} [options.content] - The markup to be used for the panel container. If empty, a JS template is used. 2771 * @param {boolean} [options.active=true] - Whether the panel is active or not. 2772 * @param {Object} [options.params] - Deprecated wrapper for the above properties. 2773 */ 2774 initialize: function ( id, options ) { 2775 var panel = this, params; 2776 params = options.params || options; 2777 2778 // Look up the type if one was not supplied. 2779 if ( ! params.type ) { 2780 _.find( api.panelConstructor, function( Constructor, type ) { 2781 if ( Constructor === panel.constructor ) { 2782 params.type = type; 2783 return true; 2784 } 2785 return false; 2786 } ); 2787 } 2788 2789 Container.prototype.initialize.call( panel, id, params ); 2790 2791 panel.embed(); 2792 panel.deferred.embedded.done( function () { 2793 panel.ready(); 2794 }); 2795 }, 2796 2797 /** 2798 * Embed the container in the DOM when any parent panel is ready. 2799 * 2800 * @since 4.1.0 2801 */ 2802 embed: function () { 2803 var panel = this, 2804 container = $( '#customize-theme-controls' ), 2805 parentContainer = $( '.customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable. 2806 2807 if ( ! panel.headContainer.parent().is( parentContainer ) ) { 2808 parentContainer.append( panel.headContainer ); 2809 } 2810 if ( ! panel.contentContainer.parent().is( panel.headContainer ) ) { 2811 container.append( panel.contentContainer ); 2812 } 2813 panel.renderContent(); 2814 2815 panel.deferred.embedded.resolve(); 2816 }, 2817 2818 /** 2819 * @since 4.1.0 2820 */ 2821 attachEvents: function () { 2822 var meta, panel = this; 2823 2824 // Expand/Collapse accordion sections on click. 2825 panel.headContainer.find( '.accordion-section-title' ).on( 'click keydown', function( event ) { 2826 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 2827 return; 2828 } 2829 event.preventDefault(); // Keep this AFTER the key filter above. 2830 2831 if ( ! panel.expanded() ) { 2832 panel.expand(); 2833 } 2834 }); 2835 2836 // Close panel. 2837 panel.container.find( '.customize-panel-back' ).on( 'click keydown', function( event ) { 2838 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 2839 return; 2840 } 2841 event.preventDefault(); // Keep this AFTER the key filter above. 2842 2843 if ( panel.expanded() ) { 2844 panel.collapse(); 2845 } 2846 }); 2847 2848 meta = panel.container.find( '.panel-meta:first' ); 2849 2850 meta.find( '> .accordion-section-title .customize-help-toggle' ).on( 'click', function() { 2851 if ( meta.hasClass( 'cannot-expand' ) ) { 2852 return; 2853 } 2854 2855 var content = meta.find( '.customize-panel-description:first' ); 2856 if ( meta.hasClass( 'open' ) ) { 2857 meta.toggleClass( 'open' ); 2858 content.slideUp( panel.defaultExpandedArguments.duration, function() { 2859 content.trigger( 'toggled' ); 2860 } ); 2861 $( this ).attr( 'aria-expanded', false ); 2862 } else { 2863 content.slideDown( panel.defaultExpandedArguments.duration, function() { 2864 content.trigger( 'toggled' ); 2865 } ); 2866 meta.toggleClass( 'open' ); 2867 $( this ).attr( 'aria-expanded', true ); 2868 } 2869 }); 2870 2871 }, 2872 2873 /** 2874 * Get the sections that are associated with this panel, sorted by their priority Value. 2875 * 2876 * @since 4.1.0 2877 * 2878 * @return {Array} 2879 */ 2880 sections: function () { 2881 return this._children( 'panel', 'section' ); 2882 }, 2883 2884 /** 2885 * Return whether this panel has any active sections. 2886 * 2887 * @since 4.1.0 2888 * 2889 * @return {boolean} Whether contextually active. 2890 */ 2891 isContextuallyActive: function () { 2892 var panel = this, 2893 sections = panel.sections(), 2894 activeCount = 0; 2895 _( sections ).each( function ( section ) { 2896 if ( section.active() && section.isContextuallyActive() ) { 2897 activeCount += 1; 2898 } 2899 } ); 2900 return ( activeCount !== 0 ); 2901 }, 2902 2903 /** 2904 * Update UI to reflect expanded state. 2905 * 2906 * @since 4.1.0 2907 * 2908 * @param {boolean} expanded 2909 * @param {Object} args 2910 * @param {boolean} args.unchanged 2911 * @param {Function} args.completeCallback 2912 * @return {void} 2913 */ 2914 onChangeExpanded: function ( expanded, args ) { 2915 2916 // Immediately call the complete callback if there were no changes. 2917 if ( args.unchanged ) { 2918 if ( args.completeCallback ) { 2919 args.completeCallback(); 2920 } 2921 return; 2922 } 2923 2924 // Note: there is a second argument 'args' passed. 2925 var panel = this, 2926 accordionSection = panel.contentContainer, 2927 overlay = accordionSection.closest( '.wp-full-overlay' ), 2928 container = accordionSection.closest( '.wp-full-overlay-sidebar-content' ), 2929 topPanel = panel.headContainer.find( '.accordion-section-title' ), 2930 backBtn = accordionSection.find( '.customize-panel-back' ), 2931 childSections = panel.sections(), 2932 skipTransition; 2933 2934 if ( expanded && ! accordionSection.hasClass( 'current-panel' ) ) { 2935 // Collapse any sibling sections/panels. 2936 api.section.each( function ( section ) { 2937 if ( panel.id !== section.panel() ) { 2938 section.collapse( { duration: 0 } ); 2939 } 2940 }); 2941 api.panel.each( function ( otherPanel ) { 2942 if ( panel !== otherPanel ) { 2943 otherPanel.collapse( { duration: 0 } ); 2944 } 2945 }); 2946 2947 if ( panel.params.autoExpandSoleSection && 1 === childSections.length && childSections[0].active.get() ) { 2948 accordionSection.addClass( 'current-panel skip-transition' ); 2949 overlay.addClass( 'in-sub-panel' ); 2950 2951 childSections[0].expand( { 2952 completeCallback: args.completeCallback 2953 } ); 2954 } else { 2955 panel._animateChangeExpanded( function() { 2956 topPanel.attr( 'tabindex', '-1' ); 2957 backBtn.attr( 'tabindex', '0' ); 2958 2959 backBtn.trigger( 'focus' ); 2960 accordionSection.css( 'top', '' ); 2961 container.scrollTop( 0 ); 2962 2963 if ( args.completeCallback ) { 2964 args.completeCallback(); 2965 } 2966 } ); 2967 2968 accordionSection.addClass( 'current-panel' ); 2969 overlay.addClass( 'in-sub-panel' ); 2970 } 2971 2972 api.state( 'expandedPanel' ).set( panel ); 2973 2974 } else if ( ! expanded && accordionSection.hasClass( 'current-panel' ) ) { 2975 skipTransition = accordionSection.hasClass( 'skip-transition' ); 2976 if ( ! skipTransition ) { 2977 panel._animateChangeExpanded( function() { 2978 topPanel.attr( 'tabindex', '0' ); 2979 backBtn.attr( 'tabindex', '-1' ); 2980 2981 topPanel.focus(); 2982 accordionSection.css( 'top', '' ); 2983 2984 if ( args.completeCallback ) { 2985 args.completeCallback(); 2986 } 2987 } ); 2988 } else { 2989 accordionSection.removeClass( 'skip-transition' ); 2990 } 2991 2992 overlay.removeClass( 'in-sub-panel' ); 2993 accordionSection.removeClass( 'current-panel' ); 2994 if ( panel === api.state( 'expandedPanel' ).get() ) { 2995 api.state( 'expandedPanel' ).set( false ); 2996 } 2997 } 2998 }, 2999 3000 /** 3001 * Render the panel from its JS template, if it exists. 3002 * 3003 * The panel's container must already exist in the DOM. 3004 * 3005 * @since 4.3.0 3006 */ 3007 renderContent: function () { 3008 var template, 3009 panel = this; 3010 3011 // Add the content to the container. 3012 if ( 0 !== $( '#tmpl-' + panel.templateSelector + '-content' ).length ) { 3013 template = wp.template( panel.templateSelector + '-content' ); 3014 } else { 3015 template = wp.template( 'customize-panel-default-content' ); 3016 } 3017 if ( template && panel.headContainer ) { 3018 panel.contentContainer.html( template( _.extend( 3019 { id: panel.id }, 3020 panel.params 3021 ) ) ); 3022 } 3023 } 3024 }); 3025 3026 api.ThemesPanel = api.Panel.extend(/** @lends wp.customize.ThemsPanel.prototype */{ 3027 3028 /** 3029 * Class wp.customize.ThemesPanel. 3030 * 3031 * Custom section for themes that displays without the customize preview. 3032 * 3033 * @constructs wp.customize.ThemesPanel 3034 * @augments wp.customize.Panel 3035 * 3036 * @since 4.9.0 3037 * 3038 * @param {string} id - The ID for the panel. 3039 * @param {Object} options - Options. 3040 * @return {void} 3041 */ 3042 initialize: function( id, options ) { 3043 var panel = this; 3044 panel.installingThemes = []; 3045 api.Panel.prototype.initialize.call( panel, id, options ); 3046 }, 3047 3048 /** 3049 * Determine whether a given theme can be switched to, or in general. 3050 * 3051 * @since 4.9.0 3052 * 3053 * @param {string} [slug] - Theme slug. 3054 * @return {boolean} Whether the theme can be switched to. 3055 */ 3056 canSwitchTheme: function canSwitchTheme( slug ) { 3057 if ( slug && slug === api.settings.theme.stylesheet ) { 3058 return true; 3059 } 3060 return 'publish' === api.state( 'selectedChangesetStatus' ).get() && ( '' === api.state( 'changesetStatus' ).get() || 'auto-draft' === api.state( 'changesetStatus' ).get() ); 3061 }, 3062 3063 /** 3064 * Attach events. 3065 * 3066 * @since 4.9.0 3067 * @return {void} 3068 */ 3069 attachEvents: function() { 3070 var panel = this; 3071 3072 // Attach regular panel events. 3073 api.Panel.prototype.attachEvents.apply( panel ); 3074 3075 // Temporary since supplying SFTP credentials does not work yet. See #42184. 3076 if ( api.settings.theme._canInstall && api.settings.theme._filesystemCredentialsNeeded ) { 3077 panel.notifications.add( new api.Notification( 'theme_install_unavailable', { 3078 message: api.l10n.themeInstallUnavailable, 3079 type: 'info', 3080 dismissible: true 3081 } ) ); 3082 } 3083 3084 function toggleDisabledNotifications() { 3085 if ( panel.canSwitchTheme() ) { 3086 panel.notifications.remove( 'theme_switch_unavailable' ); 3087 } else { 3088 panel.notifications.add( new api.Notification( 'theme_switch_unavailable', { 3089 message: api.l10n.themePreviewUnavailable, 3090 type: 'warning' 3091 } ) ); 3092 } 3093 } 3094 toggleDisabledNotifications(); 3095 api.state( 'selectedChangesetStatus' ).bind( toggleDisabledNotifications ); 3096 api.state( 'changesetStatus' ).bind( toggleDisabledNotifications ); 3097 3098 // Collapse panel to customize the current theme. 3099 panel.contentContainer.on( 'click', '.customize-theme', function() { 3100 panel.collapse(); 3101 }); 3102 3103 // Toggle between filtering and browsing themes on mobile. 3104 panel.contentContainer.on( 'click', '.customize-themes-section-title, .customize-themes-mobile-back', function() { 3105 $( '.wp-full-overlay' ).toggleClass( 'showing-themes' ); 3106 }); 3107 3108 // Install (and maybe preview) a theme. 3109 panel.contentContainer.on( 'click', '.theme-install', function( event ) { 3110 panel.installTheme( event ); 3111 }); 3112 3113 // Update a theme. Theme cards have the class, the details modal has the id. 3114 panel.contentContainer.on( 'click', '.update-theme, #update-theme', function( event ) { 3115 3116 // #update-theme is a link. 3117 event.preventDefault(); 3118 event.stopPropagation(); 3119 3120 panel.updateTheme( event ); 3121 }); 3122 3123 // Delete a theme. 3124 panel.contentContainer.on( 'click', '.delete-theme', function( event ) { 3125 panel.deleteTheme( event ); 3126 }); 3127 3128 _.bindAll( panel, 'installTheme', 'updateTheme' ); 3129 }, 3130 3131 /** 3132 * Update UI to reflect expanded state 3133 * 3134 * @since 4.9.0 3135 * 3136 * @param {boolean} expanded - Expanded state. 3137 * @param {Object} args - Args. 3138 * @param {boolean} args.unchanged - Whether or not the state changed. 3139 * @param {Function} args.completeCallback - Callback to execute when the animation completes. 3140 * @return {void} 3141 */ 3142 onChangeExpanded: function( expanded, args ) { 3143 var panel = this, overlay, sections, hasExpandedSection = false; 3144 3145 // Expand/collapse the panel normally. 3146 api.Panel.prototype.onChangeExpanded.apply( this, [ expanded, args ] ); 3147 3148 // Immediately call the complete callback if there were no changes. 3149 if ( args.unchanged ) { 3150 if ( args.completeCallback ) { 3151 args.completeCallback(); 3152 } 3153 return; 3154 } 3155 3156 overlay = panel.headContainer.closest( '.wp-full-overlay' ); 3157 3158 if ( expanded ) { 3159 overlay 3160 .addClass( 'in-themes-panel' ) 3161 .delay( 200 ).find( '.customize-themes-full-container' ).addClass( 'animate' ); 3162 3163 _.delay( function() { 3164 overlay.addClass( 'themes-panel-expanded' ); 3165 }, 200 ); 3166 3167 // Automatically open the first section (except on small screens), if one isn't already expanded. 3168 if ( 600 < window.innerWidth ) { 3169 sections = panel.sections(); 3170 _.each( sections, function( section ) { 3171 if ( section.expanded() ) { 3172 hasExpandedSection = true; 3173 } 3174 } ); 3175 if ( ! hasExpandedSection && sections.length > 0 ) { 3176 sections[0].expand(); 3177 } 3178 } 3179 } else { 3180 overlay 3181 .removeClass( 'in-themes-panel themes-panel-expanded' ) 3182 .find( '.customize-themes-full-container' ).removeClass( 'animate' ); 3183 } 3184 }, 3185 3186 /** 3187 * Install a theme via wp.updates. 3188 * 3189 * @since 4.9.0 3190 * 3191 * @param {jQuery.Event} event - Event. 3192 * @return {jQuery.promise} Promise. 3193 */ 3194 installTheme: function( event ) { 3195 var panel = this, preview, onInstallSuccess, slug = $( event.target ).data( 'slug' ), deferred = $.Deferred(), request; 3196 preview = $( event.target ).hasClass( 'preview' ); 3197 3198 // Temporary since supplying SFTP credentials does not work yet. See #42184. 3199 if ( api.settings.theme._filesystemCredentialsNeeded ) { 3200 deferred.reject({ 3201 errorCode: 'theme_install_unavailable' 3202 }); 3203 return deferred.promise(); 3204 } 3205 3206 // Prevent loading a non-active theme preview when there is a drafted/scheduled changeset. 3207 if ( ! panel.canSwitchTheme( slug ) ) { 3208 deferred.reject({ 3209 errorCode: 'theme_switch_unavailable' 3210 }); 3211 return deferred.promise(); 3212 } 3213 3214 // Theme is already being installed. 3215 if ( _.contains( panel.installingThemes, slug ) ) { 3216 deferred.reject({ 3217 errorCode: 'theme_already_installing' 3218 }); 3219 return deferred.promise(); 3220 } 3221 3222 wp.updates.maybeRequestFilesystemCredentials( event ); 3223 3224 onInstallSuccess = function( response ) { 3225 var theme = false, themeControl; 3226 if ( preview ) { 3227 api.notifications.remove( 'theme_installing' ); 3228 3229 panel.loadThemePreview( slug ); 3230 3231 } else { 3232 api.control.each( function( control ) { 3233 if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) { 3234 theme = control.params.theme; // Used below to add theme control. 3235 control.rerenderAsInstalled( true ); 3236 } 3237 }); 3238 3239 // Don't add the same theme more than once. 3240 if ( ! theme || api.control.has( 'installed_theme_' + theme.id ) ) { 3241 deferred.resolve( response ); 3242 return; 3243 } 3244 3245 // Add theme control to installed section. 3246 theme.type = 'installed'; 3247 themeControl = new api.controlConstructor.theme( 'installed_theme_' + theme.id, { 3248 type: 'theme', 3249 section: 'installed_themes', 3250 theme: theme, 3251 priority: 0 // Add all newly-installed themes to the top. 3252 } ); 3253 3254 api.control.add( themeControl ); 3255 api.control( themeControl.id ).container.trigger( 'render-screenshot' ); 3256 3257 // Close the details modal if it's open to the installed theme. 3258 api.section.each( function( section ) { 3259 if ( 'themes' === section.params.type ) { 3260 if ( theme.id === section.currentTheme ) { // Don't close the modal if the user has navigated elsewhere. 3261 section.closeDetails(); 3262 } 3263 } 3264 }); 3265 } 3266 deferred.resolve( response ); 3267 }; 3268 3269 panel.installingThemes.push( slug ); // Note: we don't remove elements from installingThemes, since they shouldn't be installed again. 3270 request = wp.updates.installTheme( { 3271 slug: slug 3272 } ); 3273 3274 // Also preview the theme as the event is triggered on Install & Preview. 3275 if ( preview ) { 3276 api.notifications.add( new api.OverlayNotification( 'theme_installing', { 3277 message: api.l10n.themeDownloading, 3278 type: 'info', 3279 loading: true 3280 } ) ); 3281 } 3282 3283 request.done( onInstallSuccess ); 3284 request.fail( function() { 3285 api.notifications.remove( 'theme_installing' ); 3286 } ); 3287 3288 return deferred.promise(); 3289 }, 3290 3291 /** 3292 * Load theme preview. 3293 * 3294 * @since 4.9.0 3295 * 3296 * @param {string} themeId Theme ID. 3297 * @return {jQuery.promise} Promise. 3298 */ 3299 loadThemePreview: function( themeId ) { 3300 var panel = this, deferred = $.Deferred(), onceProcessingComplete, urlParser, queryParams; 3301 3302 // Prevent loading a non-active theme preview when there is a drafted/scheduled changeset. 3303 if ( ! panel.canSwitchTheme( themeId ) ) { 3304 deferred.reject({ 3305 errorCode: 'theme_switch_unavailable' 3306 }); 3307 return deferred.promise(); 3308 } 3309 3310 urlParser = document.createElement( 'a' ); 3311 urlParser.href = location.href; 3312 queryParams = _.extend( 3313 api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 3314 { 3315 theme: themeId, 3316 changeset_uuid: api.settings.changeset.uuid, 3317 'return': api.settings.url['return'] 3318 } 3319 ); 3320 3321 // Include autosaved param to load autosave revision without prompting user to restore it. 3322 if ( ! api.state( 'saved' ).get() ) { 3323 queryParams.customize_autosaved = 'on'; 3324 } 3325 3326 urlParser.search = $.param( queryParams ); 3327 3328 // Update loading message. Everything else is handled by reloading the page. 3329 api.notifications.add( new api.OverlayNotification( 'theme_previewing', { 3330 message: api.l10n.themePreviewWait, 3331 type: 'info', 3332 loading: true 3333 } ) ); 3334 3335 onceProcessingComplete = function() { 3336 var request; 3337 if ( api.state( 'processing' ).get() > 0 ) { 3338 return; 3339 } 3340 3341 api.state( 'processing' ).unbind( onceProcessingComplete ); 3342 3343 request = api.requestChangesetUpdate( {}, { autosave: true } ); 3344 request.done( function() { 3345 deferred.resolve(); 3346 $( window ).off( 'beforeunload.customize-confirm' ); 3347 location.replace( urlParser.href ); 3348 } ); 3349 request.fail( function() { 3350 3351 // @todo Show notification regarding failure. 3352 api.notifications.remove( 'theme_previewing' ); 3353 3354 deferred.reject(); 3355 } ); 3356 }; 3357 3358 if ( 0 === api.state( 'processing' ).get() ) { 3359 onceProcessingComplete(); 3360 } else { 3361 api.state( 'processing' ).bind( onceProcessingComplete ); 3362 } 3363 3364 return deferred.promise(); 3365 }, 3366 3367 /** 3368 * Update a theme via wp.updates. 3369 * 3370 * @since 4.9.0 3371 * 3372 * @param {jQuery.Event} event - Event. 3373 * @return {void} 3374 */ 3375 updateTheme: function( event ) { 3376 wp.updates.maybeRequestFilesystemCredentials( event ); 3377 3378 $( document ).one( 'wp-theme-update-success', function( e, response ) { 3379 3380 // Rerender the control to reflect the update. 3381 api.control.each( function( control ) { 3382 if ( 'theme' === control.params.type && control.params.theme.id === response.slug ) { 3383 control.params.theme.hasUpdate = false; 3384 control.params.theme.version = response.newVersion; 3385 setTimeout( function() { 3386 control.rerenderAsInstalled( true ); 3387 }, 2000 ); 3388 } 3389 }); 3390 } ); 3391 3392 wp.updates.updateTheme( { 3393 slug: $( event.target ).closest( '.notice' ).data( 'slug' ) 3394 } ); 3395 }, 3396 3397 /** 3398 * Delete a theme via wp.updates. 3399 * 3400 * @since 4.9.0 3401 * 3402 * @param {jQuery.Event} event - Event. 3403 * @return {void} 3404 */ 3405 deleteTheme: function( event ) { 3406 var theme, section; 3407 theme = $( event.target ).data( 'slug' ); 3408 section = api.section( 'installed_themes' ); 3409 3410 event.preventDefault(); 3411 3412 // Temporary since supplying SFTP credentials does not work yet. See #42184. 3413 if ( api.settings.theme._filesystemCredentialsNeeded ) { 3414 return; 3415 } 3416 3417 // Confirmation dialog for deleting a theme. 3418 if ( ! window.confirm( api.settings.l10n.confirmDeleteTheme ) ) { 3419 return; 3420 } 3421 3422 wp.updates.maybeRequestFilesystemCredentials( event ); 3423 3424 $( document ).one( 'wp-theme-delete-success', function() { 3425 var control = api.control( 'installed_theme_' + theme ); 3426 3427 // Remove theme control. 3428 control.container.remove(); 3429 api.control.remove( control.id ); 3430 3431 // Update installed count. 3432 section.loaded = section.loaded - 1; 3433 section.updateCount(); 3434 3435 // Rerender any other theme controls as uninstalled. 3436 api.control.each( function( control ) { 3437 if ( 'theme' === control.params.type && control.params.theme.id === theme ) { 3438 control.rerenderAsInstalled( false ); 3439 } 3440 }); 3441 } ); 3442 3443 wp.updates.deleteTheme( { 3444 slug: theme 3445 } ); 3446 3447 // Close modal and focus the section. 3448 section.closeDetails(); 3449 section.focus(); 3450 } 3451 }); 3452 3453 api.Control = api.Class.extend(/** @lends wp.customize.Control.prototype */{ 3454 defaultActiveArguments: { duration: 'fast', completeCallback: $.noop }, 3455 3456 /** 3457 * Default params. 3458 * 3459 * @since 4.9.0 3460 * @var {object} 3461 */ 3462 defaults: { 3463 label: '', 3464 description: '', 3465 active: true, 3466 priority: 10 3467 }, 3468 3469 /** 3470 * A Customizer Control. 3471 * 3472 * A control provides a UI element that allows a user to modify a Customizer Setting. 3473 * 3474 * @see PHP class WP_Customize_Control. 3475 * 3476 * @constructs wp.customize.Control 3477 * @augments wp.customize.Class 3478 * 3479 * @borrows wp.customize~focus as this#focus 3480 * @borrows wp.customize~Container#activate as this#activate 3481 * @borrows wp.customize~Container#deactivate as this#deactivate 3482 * @borrows wp.customize~Container#_toggleActive as this#_toggleActive 3483 * 3484 * @param {string} id - Unique identifier for the control instance. 3485 * @param {Object} options - Options hash for the control instance. 3486 * @param {Object} options.type - Type of control (e.g. text, radio, dropdown-pages, etc.) 3487 * @param {string} [options.content] - The HTML content for the control or at least its container. This should normally be left blank and instead supplying a templateId. 3488 * @param {string} [options.templateId] - Template ID for control's content. 3489 * @param {string} [options.priority=10] - Order of priority to show the control within the section. 3490 * @param {string} [options.active=true] - Whether the control is active. 3491 * @param {string} options.section - The ID of the section the control belongs to. 3492 * @param {mixed} [options.setting] - The ID of the main setting or an instance of this setting. 3493 * @param {mixed} options.settings - An object with keys (e.g. default) that maps to setting IDs or Setting/Value objects, or an array of setting IDs or Setting/Value objects. 3494 * @param {mixed} options.settings.default - The ID of the setting the control relates to. 3495 * @param {string} options.settings.data - @todo Is this used? 3496 * @param {string} options.label - Label. 3497 * @param {string} options.description - Description. 3498 * @param {number} [options.instanceNumber] - Order in which this instance was created in relation to other instances. 3499 * @param {Object} [options.params] - Deprecated wrapper for the above properties. 3500 * @return {void} 3501 */ 3502 initialize: function( id, options ) { 3503 var control = this, deferredSettingIds = [], settings, gatherSettings; 3504 3505 control.params = _.extend( 3506 {}, 3507 control.defaults, 3508 control.params || {}, // In case subclass already defines. 3509 options.params || options || {} // The options.params property is deprecated, but it is checked first for back-compat. 3510 ); 3511 3512 if ( ! api.Control.instanceCounter ) { 3513 api.Control.instanceCounter = 0; 3514 } 3515 api.Control.instanceCounter++; 3516 if ( ! control.params.instanceNumber ) { 3517 control.params.instanceNumber = api.Control.instanceCounter; 3518 } 3519 3520 // Look up the type if one was not supplied. 3521 if ( ! control.params.type ) { 3522 _.find( api.controlConstructor, function( Constructor, type ) { 3523 if ( Constructor === control.constructor ) { 3524 control.params.type = type; 3525 return true; 3526 } 3527 return false; 3528 } ); 3529 } 3530 3531 if ( ! control.params.content ) { 3532 control.params.content = $( '<li></li>', { 3533 id: 'customize-control-' + id.replace( /]/g, '' ).replace( /\[/g, '-' ), 3534 'class': 'customize-control customize-control-' + control.params.type 3535 } ); 3536 } 3537 3538 control.id = id; 3539 control.selector = '#customize-control-' + id.replace( /\]/g, '' ).replace( /\[/g, '-' ); // Deprecated, likely dead code from time before #28709. 3540 if ( control.params.content ) { 3541 control.container = $( control.params.content ); 3542 } else { 3543 control.container = $( control.selector ); // Likely dead, per above. See #28709. 3544 } 3545 3546 if ( control.params.templateId ) { 3547 control.templateSelector = control.params.templateId; 3548 } else { 3549 control.templateSelector = 'customize-control-' + control.params.type + '-content'; 3550 } 3551 3552 control.deferred = _.extend( control.deferred || {}, { 3553 embedded: new $.Deferred() 3554 } ); 3555 control.section = new api.Value(); 3556 control.priority = new api.Value(); 3557 control.active = new api.Value(); 3558 control.activeArgumentsQueue = []; 3559 control.notifications = new api.Notifications({ 3560 alt: control.altNotice 3561 }); 3562 3563 control.elements = []; 3564 3565 control.active.bind( function ( active ) { 3566 var args = control.activeArgumentsQueue.shift(); 3567 args = $.extend( {}, control.defaultActiveArguments, args ); 3568 control.onChangeActive( active, args ); 3569 } ); 3570 3571 control.section.set( control.params.section ); 3572 control.priority.set( isNaN( control.params.priority ) ? 10 : control.params.priority ); 3573 control.active.set( control.params.active ); 3574 3575 api.utils.bubbleChildValueChanges( control, [ 'section', 'priority', 'active' ] ); 3576 3577 control.settings = {}; 3578 3579 settings = {}; 3580 if ( control.params.setting ) { 3581 settings['default'] = control.params.setting; 3582 } 3583 _.extend( settings, control.params.settings ); 3584 3585 // Note: Settings can be an array or an object, with values being either setting IDs or Setting (or Value) objects. 3586 _.each( settings, function( value, key ) { 3587 var setting; 3588 if ( _.isObject( value ) && _.isFunction( value.extended ) && value.extended( api.Value ) ) { 3589 control.settings[ key ] = value; 3590 } else if ( _.isString( value ) ) { 3591 setting = api( value ); 3592 if ( setting ) { 3593 control.settings[ key ] = setting; 3594 } else { 3595 deferredSettingIds.push( value ); 3596 } 3597 } 3598 } ); 3599 3600 gatherSettings = function() { 3601 3602 // Fill-in all resolved settings. 3603 _.each( settings, function ( settingId, key ) { 3604 if ( ! control.settings[ key ] && _.isString( settingId ) ) { 3605 control.settings[ key ] = api( settingId ); 3606 } 3607 } ); 3608 3609 // Make sure settings passed as array gets associated with default. 3610 if ( control.settings[0] && ! control.settings['default'] ) { 3611 control.settings['default'] = control.settings[0]; 3612 } 3613 3614 // Identify the main setting. 3615 control.setting = control.settings['default'] || null; 3616 3617 control.linkElements(); // Link initial elements present in server-rendered content. 3618 control.embed(); 3619 }; 3620 3621 if ( 0 === deferredSettingIds.length ) { 3622 gatherSettings(); 3623 } else { 3624 api.apply( api, deferredSettingIds.concat( gatherSettings ) ); 3625 } 3626 3627 // After the control is embedded on the page, invoke the "ready" method. 3628 control.deferred.embedded.done( function () { 3629 control.linkElements(); // Link any additional elements after template is rendered by renderContent(). 3630 control.setupNotifications(); 3631 control.ready(); 3632 }); 3633 }, 3634 3635 /** 3636 * Link elements between settings and inputs. 3637 * 3638 * @since 4.7.0 3639 * @access public 3640 * 3641 * @return {void} 3642 */ 3643 linkElements: function () { 3644 var control = this, nodes, radios, element; 3645 3646 nodes = control.container.find( '[data-customize-setting-link], [data-customize-setting-key-link]' ); 3647 radios = {}; 3648 3649 nodes.each( function () { 3650 var node = $( this ), name, setting; 3651 3652 if ( node.data( 'customizeSettingLinked' ) ) { 3653 return; 3654 } 3655 node.data( 'customizeSettingLinked', true ); // Prevent re-linking element. 3656 3657 if ( node.is( ':radio' ) ) { 3658 name = node.prop( 'name' ); 3659 if ( radios[name] ) { 3660 return; 3661 } 3662 3663 radios[name] = true; 3664 node = nodes.filter( '[name="' + name + '"]' ); 3665 } 3666 3667 // Let link by default refer to setting ID. If it doesn't exist, fallback to looking up by setting key. 3668 if ( node.data( 'customizeSettingLink' ) ) { 3669 setting = api( node.data( 'customizeSettingLink' ) ); 3670 } else if ( node.data( 'customizeSettingKeyLink' ) ) { 3671 setting = control.settings[ node.data( 'customizeSettingKeyLink' ) ]; 3672 } 3673 3674 if ( setting ) { 3675 element = new api.Element( node ); 3676 control.elements.push( element ); 3677 element.sync( setting ); 3678 element.set( setting() ); 3679 } 3680 } ); 3681 }, 3682 3683 /** 3684 * Embed the control into the page. 3685 */ 3686 embed: function () { 3687 var control = this, 3688 inject; 3689 3690 // Watch for changes to the section state. 3691 inject = function ( sectionId ) { 3692 var parentContainer; 3693 if ( ! sectionId ) { // @todo Allow a control to be embedded without a section, for instance a control embedded in the front end. 3694 return; 3695 } 3696 // Wait for the section to be registered. 3697 api.section( sectionId, function ( section ) { 3698 // Wait for the section to be ready/initialized. 3699 section.deferred.embedded.done( function () { 3700 parentContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' ); 3701 if ( ! control.container.parent().is( parentContainer ) ) { 3702 parentContainer.append( control.container ); 3703 } 3704 control.renderContent(); 3705 control.deferred.embedded.resolve(); 3706 }); 3707 }); 3708 }; 3709 control.section.bind( inject ); 3710 inject( control.section.get() ); 3711 }, 3712 3713 /** 3714 * Triggered when the control's markup has been injected into the DOM. 3715 * 3716 * @return {void} 3717 */ 3718 ready: function() { 3719 var control = this, newItem; 3720 if ( 'dropdown-pages' === control.params.type && control.params.allow_addition ) { 3721 newItem = control.container.find( '.new-content-item' ); 3722 newItem.hide(); // Hide in JS to preserve flex display when showing. 3723 control.container.on( 'click', '.add-new-toggle', function( e ) { 3724 $( e.currentTarget ).slideUp( 180 ); 3725 newItem.slideDown( 180 ); 3726 newItem.find( '.create-item-input' ).focus(); 3727 }); 3728 control.container.on( 'click', '.add-content', function() { 3729 control.addNewPage(); 3730 }); 3731 control.container.on( 'keydown', '.create-item-input', function( e ) { 3732 if ( 13 === e.which ) { // Enter. 3733 control.addNewPage(); 3734 } 3735 }); 3736 } 3737 }, 3738 3739 /** 3740 * Get the element inside of a control's container that contains the validation error message. 3741 * 3742 * Control subclasses may override this to return the proper container to render notifications into. 3743 * Injects the notification container for existing controls that lack the necessary container, 3744 * including special handling for nav menu items and widgets. 3745 * 3746 * @since 4.6.0 3747 * @return {jQuery} Setting validation message element. 3748 */ 3749 getNotificationsContainerElement: function() { 3750 var control = this, controlTitle, notificationsContainer; 3751 3752 notificationsContainer = control.container.find( '.customize-control-notifications-container:first' ); 3753 if ( notificationsContainer.length ) { 3754 return notificationsContainer; 3755 } 3756 3757 notificationsContainer = $( '<div class="customize-control-notifications-container"></div>' ); 3758 3759 if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) { 3760 control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer ); 3761 } else if ( control.container.hasClass( 'customize-control-widget_form' ) ) { 3762 control.container.find( '.widget-inside:first' ).prepend( notificationsContainer ); 3763 } else { 3764 controlTitle = control.container.find( '.customize-control-title' ); 3765 if ( controlTitle.length ) { 3766 controlTitle.after( notificationsContainer ); 3767 } else { 3768 control.container.prepend( notificationsContainer ); 3769 } 3770 } 3771 return notificationsContainer; 3772 }, 3773 3774 /** 3775 * Set up notifications. 3776 * 3777 * @since 4.9.0 3778 * @return {void} 3779 */ 3780 setupNotifications: function() { 3781 var control = this, renderNotificationsIfVisible, onSectionAssigned; 3782 3783 // Add setting notifications to the control notification. 3784 _.each( control.settings, function( setting ) { 3785 if ( ! setting.notifications ) { 3786 return; 3787 } 3788 setting.notifications.bind( 'add', function( settingNotification ) { 3789 var params = _.extend( 3790 {}, 3791 settingNotification, 3792 { 3793 setting: setting.id 3794 } 3795 ); 3796 control.notifications.add( new api.Notification( setting.id + ':' + settingNotification.code, params ) ); 3797 } ); 3798 setting.notifications.bind( 'remove', function( settingNotification ) { 3799 control.notifications.remove( setting.id + ':' + settingNotification.code ); 3800 } ); 3801 } ); 3802 3803 renderNotificationsIfVisible = function() { 3804 var sectionId = control.section(); 3805 if ( ! sectionId || ( api.section.has( sectionId ) && api.section( sectionId ).expanded() ) ) { 3806 control.notifications.render(); 3807 } 3808 }; 3809 3810 control.notifications.bind( 'rendered', function() { 3811 var notifications = control.notifications.get(); 3812 control.container.toggleClass( 'has-notifications', 0 !== notifications.length ); 3813 control.container.toggleClass( 'has-error', 0 !== _.where( notifications, { type: 'error' } ).length ); 3814 } ); 3815 3816 onSectionAssigned = function( newSectionId, oldSectionId ) { 3817 if ( oldSectionId && api.section.has( oldSectionId ) ) { 3818 api.section( oldSectionId ).expanded.unbind( renderNotificationsIfVisible ); 3819 } 3820 if ( newSectionId ) { 3821 api.section( newSectionId, function( section ) { 3822 section.expanded.bind( renderNotificationsIfVisible ); 3823 renderNotificationsIfVisible(); 3824 }); 3825 } 3826 }; 3827 3828 control.section.bind( onSectionAssigned ); 3829 onSectionAssigned( control.section.get() ); 3830 control.notifications.bind( 'change', _.debounce( renderNotificationsIfVisible ) ); 3831 }, 3832 3833 /** 3834 * Render notifications. 3835 * 3836 * Renders the `control.notifications` into the control's container. 3837 * Control subclasses may override this method to do their own handling 3838 * of rendering notifications. 3839 * 3840 * @deprecated in favor of `control.notifications.render()` 3841 * @since 4.6.0 3842 * @this {wp.customize.Control} 3843 */ 3844 renderNotifications: function() { 3845 var control = this, container, notifications, hasError = false; 3846 3847 if ( 'undefined' !== typeof console && console.warn ) { 3848 console.warn( '[DEPRECATED] wp.customize.Control.prototype.renderNotifications() is deprecated in favor of instantating a wp.customize.Notifications and calling its render() method.' ); 3849 } 3850 3851 container = control.getNotificationsContainerElement(); 3852 if ( ! container || ! container.length ) { 3853 return; 3854 } 3855 notifications = []; 3856 control.notifications.each( function( notification ) { 3857 notifications.push( notification ); 3858 if ( 'error' === notification.type ) { 3859 hasError = true; 3860 } 3861 } ); 3862 3863 if ( 0 === notifications.length ) { 3864 container.stop().slideUp( 'fast' ); 3865 } else { 3866 container.stop().slideDown( 'fast', null, function() { 3867 $( this ).css( 'height', 'auto' ); 3868 } ); 3869 } 3870 3871 if ( ! control.notificationsTemplate ) { 3872 control.notificationsTemplate = wp.template( 'customize-control-notifications' ); 3873 } 3874 3875 control.container.toggleClass( 'has-notifications', 0 !== notifications.length ); 3876 control.container.toggleClass( 'has-error', hasError ); 3877 container.empty().append( 3878 control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } ).trim() 3879 ); 3880 }, 3881 3882 /** 3883 * Normal controls do not expand, so just expand its parent 3884 * 3885 * @param {Object} [params] 3886 */ 3887 expand: function ( params ) { 3888 api.section( this.section() ).expand( params ); 3889 }, 3890 3891 /* 3892 * Documented using @borrows in the constructor. 3893 */ 3894 focus: focus, 3895 3896 /** 3897 * Update UI in response to a change in the control's active state. 3898 * This does not change the active state, it merely handles the behavior 3899 * for when it does change. 3900 * 3901 * @since 4.1.0 3902 * 3903 * @param {boolean} active 3904 * @param {Object} args 3905 * @param {number} args.duration 3906 * @param {Function} args.completeCallback 3907 */ 3908 onChangeActive: function ( active, args ) { 3909 if ( args.unchanged ) { 3910 if ( args.completeCallback ) { 3911 args.completeCallback(); 3912 } 3913 return; 3914 } 3915 3916 if ( ! $.contains( document, this.container[0] ) ) { 3917 // jQuery.fn.slideUp is not hiding an element if it is not in the DOM. 3918 this.container.toggle( active ); 3919 if ( args.completeCallback ) { 3920 args.completeCallback(); 3921 } 3922 } else if ( active ) { 3923 this.container.slideDown( args.duration, args.completeCallback ); 3924 } else { 3925 this.container.slideUp( args.duration, args.completeCallback ); 3926 } 3927 }, 3928 3929 /** 3930 * @deprecated 4.1.0 Use this.onChangeActive() instead. 3931 */ 3932 toggle: function ( active ) { 3933 return this.onChangeActive( active, this.defaultActiveArguments ); 3934 }, 3935 3936 /* 3937 * Documented using @borrows in the constructor 3938 */ 3939 activate: Container.prototype.activate, 3940 3941 /* 3942 * Documented using @borrows in the constructor 3943 */ 3944 deactivate: Container.prototype.deactivate, 3945 3946 /* 3947 * Documented using @borrows in the constructor 3948 */ 3949 _toggleActive: Container.prototype._toggleActive, 3950 3951 // @todo This function appears to be dead code and can be removed. 3952 dropdownInit: function() { 3953 var control = this, 3954 statuses = this.container.find('.dropdown-status'), 3955 params = this.params, 3956 toggleFreeze = false, 3957 update = function( to ) { 3958 if ( 'string' === typeof to && params.statuses && params.statuses[ to ] ) { 3959 statuses.html( params.statuses[ to ] ).show(); 3960 } else { 3961 statuses.hide(); 3962 } 3963 }; 3964 3965 // Support the .dropdown class to open/close complex elements. 3966 this.container.on( 'click keydown', '.dropdown', function( event ) { 3967 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 3968 return; 3969 } 3970 3971 event.preventDefault(); 3972 3973 if ( ! toggleFreeze ) { 3974 control.container.toggleClass( 'open' ); 3975 } 3976 3977 if ( control.container.hasClass( 'open' ) ) { 3978 control.container.parent().parent().find( 'li.library-selected' ).focus(); 3979 } 3980 3981 // Don't want to fire focus and click at same time. 3982 toggleFreeze = true; 3983 setTimeout(function () { 3984 toggleFreeze = false; 3985 }, 400); 3986 }); 3987 3988 this.setting.bind( update ); 3989 update( this.setting() ); 3990 }, 3991 3992 /** 3993 * Render the control from its JS template, if it exists. 3994 * 3995 * The control's container must already exist in the DOM. 3996 * 3997 * @since 4.1.0 3998 */ 3999 renderContent: function () { 4000 var control = this, template, standardTypes, templateId, sectionId; 4001 4002 standardTypes = [ 4003 'button', 4004 'checkbox', 4005 'date', 4006 'datetime-local', 4007 'email', 4008 'month', 4009 'number', 4010 'password', 4011 'radio', 4012 'range', 4013 'search', 4014 'select', 4015 'tel', 4016 'time', 4017 'text', 4018 'textarea', 4019 'week', 4020 'url' 4021 ]; 4022 4023 templateId = control.templateSelector; 4024 4025 // Use default content template when a standard HTML type is used, 4026 // there isn't a more specific template existing, and the control container is empty. 4027 if ( templateId === 'customize-control-' + control.params.type + '-content' && 4028 _.contains( standardTypes, control.params.type ) && 4029 ! document.getElementById( 'tmpl-' + templateId ) && 4030 0 === control.container.children().length ) 4031 { 4032 templateId = 'customize-control-default-content'; 4033 } 4034 4035 // Replace the container element's content with the control. 4036 if ( document.getElementById( 'tmpl-' + templateId ) ) { 4037 template = wp.template( templateId ); 4038 if ( template && control.container ) { 4039 control.container.html( template( control.params ) ); 4040 } 4041 } 4042 4043 // Re-render notifications after content has been re-rendered. 4044 control.notifications.container = control.getNotificationsContainerElement(); 4045 sectionId = control.section(); 4046 if ( ! sectionId || ( api.section.has( sectionId ) && api.section( sectionId ).expanded() ) ) { 4047 control.notifications.render(); 4048 } 4049 }, 4050 4051 /** 4052 * Add a new page to a dropdown-pages control reusing menus code for this. 4053 * 4054 * @since 4.7.0 4055 * @access private 4056 * 4057 * @return {void} 4058 */ 4059 addNewPage: function () { 4060 var control = this, promise, toggle, container, input, title, select; 4061 4062 if ( 'dropdown-pages' !== control.params.type || ! control.params.allow_addition || ! api.Menus ) { 4063 return; 4064 } 4065 4066 toggle = control.container.find( '.add-new-toggle' ); 4067 container = control.container.find( '.new-content-item' ); 4068 input = control.container.find( '.create-item-input' ); 4069 title = input.val(); 4070 select = control.container.find( 'select' ); 4071 4072 if ( ! title ) { 4073 input.addClass( 'invalid' ); 4074 return; 4075 } 4076 4077 input.removeClass( 'invalid' ); 4078 input.attr( 'disabled', 'disabled' ); 4079 4080 // The menus functions add the page, publish when appropriate, 4081 // and also add the new page to the dropdown-pages controls. 4082 promise = api.Menus.insertAutoDraftPost( { 4083 post_title: title, 4084 post_type: 'page' 4085 } ); 4086 promise.done( function( data ) { 4087 var availableItem, $content, itemTemplate; 4088 4089 // Prepare the new page as an available menu item. 4090 // See api.Menus.submitNew(). 4091 availableItem = new api.Menus.AvailableItemModel( { 4092 'id': 'post-' + data.post_id, // Used for available menu item Backbone models. 4093 'title': title, 4094 'type': 'post_type', 4095 'type_label': api.Menus.data.l10n.page_label, 4096 'object': 'page', 4097 'object_id': data.post_id, 4098 'url': data.url 4099 } ); 4100 4101 // Add the new item to the list of available menu items. 4102 api.Menus.availableMenuItemsPanel.collection.add( availableItem ); 4103 $content = $( '#available-menu-items-post_type-page' ).find( '.available-menu-items-list' ); 4104 itemTemplate = wp.template( 'available-menu-item' ); 4105 $content.prepend( itemTemplate( availableItem.attributes ) ); 4106 4107 // Focus the select control. 4108 select.focus(); 4109 control.setting.set( String( data.post_id ) ); // Triggers a preview refresh and updates the setting. 4110 4111 // Reset the create page form. 4112 container.slideUp( 180 ); 4113 toggle.slideDown( 180 ); 4114 } ); 4115 promise.always( function() { 4116 input.val( '' ).removeAttr( 'disabled' ); 4117 } ); 4118 } 4119 }); 4120 4121 /** 4122 * A colorpicker control. 4123 * 4124 * @class wp.customize.ColorControl 4125 * @augments wp.customize.Control 4126 */ 4127 api.ColorControl = api.Control.extend(/** @lends wp.customize.ColorControl.prototype */{ 4128 ready: function() { 4129 var control = this, 4130 isHueSlider = this.params.mode === 'hue', 4131 updating = false, 4132 picker; 4133 4134 if ( isHueSlider ) { 4135 picker = this.container.find( '.color-picker-hue' ); 4136 picker.val( control.setting() ).wpColorPicker({ 4137 change: function( event, ui ) { 4138 updating = true; 4139 control.setting( ui.color.h() ); 4140 updating = false; 4141 } 4142 }); 4143 } else { 4144 picker = this.container.find( '.color-picker-hex' ); 4145 picker.val( control.setting() ).wpColorPicker({ 4146 change: function() { 4147 updating = true; 4148 control.setting.set( picker.wpColorPicker( 'color' ) ); 4149 updating = false; 4150 }, 4151 clear: function() { 4152 updating = true; 4153 control.setting.set( '' ); 4154 updating = false; 4155 } 4156 }); 4157 } 4158 4159 control.setting.bind( function ( value ) { 4160 // Bail if the update came from the control itself. 4161 if ( updating ) { 4162 return; 4163 } 4164 picker.val( value ); 4165 picker.wpColorPicker( 'color', value ); 4166 } ); 4167 4168 // Collapse color picker when hitting Esc instead of collapsing the current section. 4169 control.container.on( 'keydown', function( event ) { 4170 var pickerContainer; 4171 if ( 27 !== event.which ) { // Esc. 4172 return; 4173 } 4174 pickerContainer = control.container.find( '.wp-picker-container' ); 4175 if ( pickerContainer.hasClass( 'wp-picker-active' ) ) { 4176 picker.wpColorPicker( 'close' ); 4177 control.container.find( '.wp-color-result' ).focus(); 4178 event.stopPropagation(); // Prevent section from being collapsed. 4179 } 4180 } ); 4181 } 4182 }); 4183 4184 /** 4185 * A control that implements the media modal. 4186 * 4187 * @class wp.customize.MediaControl 4188 * @augments wp.customize.Control 4189 */ 4190 api.MediaControl = api.Control.extend(/** @lends wp.customize.MediaControl.prototype */{ 4191 4192 /** 4193 * When the control's DOM structure is ready, 4194 * set up internal event bindings. 4195 */ 4196 ready: function() { 4197 var control = this; 4198 // Shortcut so that we don't have to use _.bind every time we add a callback. 4199 _.bindAll( control, 'restoreDefault', 'removeFile', 'openFrame', 'select', 'pausePlayer' ); 4200 4201 // Bind events, with delegation to facilitate re-rendering. 4202 control.container.on( 'click keydown', '.upload-button', control.openFrame ); 4203 control.container.on( 'click keydown', '.upload-button', control.pausePlayer ); 4204 control.container.on( 'click keydown', '.thumbnail-image img', control.openFrame ); 4205 control.container.on( 'click keydown', '.default-button', control.restoreDefault ); 4206 control.container.on( 'click keydown', '.remove-button', control.pausePlayer ); 4207 control.container.on( 'click keydown', '.remove-button', control.removeFile ); 4208 control.container.on( 'click keydown', '.remove-button', control.cleanupPlayer ); 4209 4210 // Resize the player controls when it becomes visible (ie when section is expanded). 4211 api.section( control.section() ).container 4212 .on( 'expanded', function() { 4213 if ( control.player ) { 4214 control.player.setControlsSize(); 4215 } 4216 }) 4217 .on( 'collapsed', function() { 4218 control.pausePlayer(); 4219 }); 4220 4221 /** 4222 * Set attachment data and render content. 4223 * 4224 * Note that BackgroundImage.prototype.ready applies this ready method 4225 * to itself. Since BackgroundImage is an UploadControl, the value 4226 * is the attachment URL instead of the attachment ID. In this case 4227 * we skip fetching the attachment data because we have no ID available, 4228 * and it is the responsibility of the UploadControl to set the control's 4229 * attachmentData before calling the renderContent method. 4230 * 4231 * @param {number|string} value Attachment 4232 */ 4233 function setAttachmentDataAndRenderContent( value ) { 4234 var hasAttachmentData = $.Deferred(); 4235 4236 if ( control.extended( api.UploadControl ) ) { 4237 hasAttachmentData.resolve(); 4238 } else { 4239 value = parseInt( value, 10 ); 4240 if ( _.isNaN( value ) || value <= 0 ) { 4241 delete control.params.attachment; 4242 hasAttachmentData.resolve(); 4243 } else if ( control.params.attachment && control.params.attachment.id === value ) { 4244 hasAttachmentData.resolve(); 4245 } 4246 } 4247 4248 // Fetch the attachment data. 4249 if ( 'pending' === hasAttachmentData.state() ) { 4250 wp.media.attachment( value ).fetch().done( function() { 4251 control.params.attachment = this.attributes; 4252 hasAttachmentData.resolve(); 4253 4254 // Send attachment information to the preview for possible use in `postMessage` transport. 4255 wp.customize.previewer.send( control.setting.id + '-attachment-data', this.attributes ); 4256 } ); 4257 } 4258 4259 hasAttachmentData.done( function() { 4260 control.renderContent(); 4261 } ); 4262 } 4263 4264 // Ensure attachment data is initially set (for dynamically-instantiated controls). 4265 setAttachmentDataAndRenderContent( control.setting() ); 4266 4267 // Update the attachment data and re-render the control when the setting changes. 4268 control.setting.bind( setAttachmentDataAndRenderContent ); 4269 }, 4270 4271 pausePlayer: function () { 4272 this.player && this.player.pause(); 4273 }, 4274 4275 cleanupPlayer: function () { 4276 this.player && wp.media.mixin.removePlayer( this.player ); 4277 }, 4278 4279 /** 4280 * Open the media modal. 4281 */ 4282 openFrame: function( event ) { 4283 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4284 return; 4285 } 4286 4287 event.preventDefault(); 4288 4289 if ( ! this.frame ) { 4290 this.initFrame(); 4291 } 4292 4293 this.frame.open(); 4294 }, 4295 4296 /** 4297 * Create a media modal select frame, and store it so the instance can be reused when needed. 4298 */ 4299 initFrame: function() { 4300 this.frame = wp.media({ 4301 button: { 4302 text: this.params.button_labels.frame_button 4303 }, 4304 states: [ 4305 new wp.media.controller.Library({ 4306 title: this.params.button_labels.frame_title, 4307 library: wp.media.query({ type: this.params.mime_type }), 4308 multiple: false, 4309 date: false 4310 }) 4311 ] 4312 }); 4313 4314 // When a file is selected, run a callback. 4315 this.frame.on( 'select', this.select ); 4316 }, 4317 4318 /** 4319 * Callback handler for when an attachment is selected in the media modal. 4320 * Gets the selected image information, and sets it within the control. 4321 */ 4322 select: function() { 4323 // Get the attachment from the modal frame. 4324 var node, 4325 attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4326 mejsSettings = window._wpmejsSettings || {}; 4327 4328 this.params.attachment = attachment; 4329 4330 // Set the Customizer setting; the callback takes care of rendering. 4331 this.setting( attachment.id ); 4332 node = this.container.find( 'audio, video' ).get(0); 4333 4334 // Initialize audio/video previews. 4335 if ( node ) { 4336 this.player = new MediaElementPlayer( node, mejsSettings ); 4337 } else { 4338 this.cleanupPlayer(); 4339 } 4340 }, 4341 4342 /** 4343 * Reset the setting to the default value. 4344 */ 4345 restoreDefault: function( event ) { 4346 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4347 return; 4348 } 4349 event.preventDefault(); 4350 4351 this.params.attachment = this.params.defaultAttachment; 4352 this.setting( this.params.defaultAttachment.url ); 4353 }, 4354 4355 /** 4356 * Called when the "Remove" link is clicked. Empties the setting. 4357 * 4358 * @param {Object} event jQuery Event object 4359 */ 4360 removeFile: function( event ) { 4361 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4362 return; 4363 } 4364 event.preventDefault(); 4365 4366 this.params.attachment = {}; 4367 this.setting( '' ); 4368 this.renderContent(); // Not bound to setting change when emptying. 4369 } 4370 }); 4371 4372 /** 4373 * An upload control, which utilizes the media modal. 4374 * 4375 * @class wp.customize.UploadControl 4376 * @augments wp.customize.MediaControl 4377 */ 4378 api.UploadControl = api.MediaControl.extend(/** @lends wp.customize.UploadControl.prototype */{ 4379 4380 /** 4381 * Callback handler for when an attachment is selected in the media modal. 4382 * Gets the selected image information, and sets it within the control. 4383 */ 4384 select: function() { 4385 // Get the attachment from the modal frame. 4386 var node, 4387 attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4388 mejsSettings = window._wpmejsSettings || {}; 4389 4390 this.params.attachment = attachment; 4391 4392 // Set the Customizer setting; the callback takes care of rendering. 4393 this.setting( attachment.url ); 4394 node = this.container.find( 'audio, video' ).get(0); 4395 4396 // Initialize audio/video previews. 4397 if ( node ) { 4398 this.player = new MediaElementPlayer( node, mejsSettings ); 4399 } else { 4400 this.cleanupPlayer(); 4401 } 4402 }, 4403 4404 // @deprecated 4405 success: function() {}, 4406 4407 // @deprecated 4408 removerVisibility: function() {} 4409 }); 4410 4411 /** 4412 * A control for uploading images. 4413 * 4414 * This control no longer needs to do anything more 4415 * than what the upload control does in JS. 4416 * 4417 * @class wp.customize.ImageControl 4418 * @augments wp.customize.UploadControl 4419 */ 4420 api.ImageControl = api.UploadControl.extend(/** @lends wp.customize.ImageControl.prototype */{ 4421 // @deprecated 4422 thumbnailSrc: function() {} 4423 }); 4424 4425 /** 4426 * A control for uploading background images. 4427 * 4428 * @class wp.customize.BackgroundControl 4429 * @augments wp.customize.UploadControl 4430 */ 4431 api.BackgroundControl = api.UploadControl.extend(/** @lends wp.customize.BackgroundControl.prototype */{ 4432 4433 /** 4434 * When the control's DOM structure is ready, 4435 * set up internal event bindings. 4436 */ 4437 ready: function() { 4438 api.UploadControl.prototype.ready.apply( this, arguments ); 4439 }, 4440 4441 /** 4442 * Callback handler for when an attachment is selected in the media modal. 4443 * Does an additional Ajax request for setting the background context. 4444 */ 4445 select: function() { 4446 api.UploadControl.prototype.select.apply( this, arguments ); 4447 4448 wp.ajax.post( 'custom-background-add', { 4449 nonce: _wpCustomizeBackground.nonces.add, 4450 wp_customize: 'on', 4451 customize_theme: api.settings.theme.stylesheet, 4452 attachment_id: this.params.attachment.id 4453 } ); 4454 } 4455 }); 4456 4457 /** 4458 * A control for positioning a background image. 4459 * 4460 * @since 4.7.0 4461 * 4462 * @class wp.customize.BackgroundPositionControl 4463 * @augments wp.customize.Control 4464 */ 4465 api.BackgroundPositionControl = api.Control.extend(/** @lends wp.customize.BackgroundPositionControl.prototype */{ 4466 4467 /** 4468 * Set up control UI once embedded in DOM and settings are created. 4469 * 4470 * @since 4.7.0 4471 * @access public 4472 */ 4473 ready: function() { 4474 var control = this, updateRadios; 4475 4476 control.container.on( 'change', 'input[name="background-position"]', function() { 4477 var position = $( this ).val().split( ' ' ); 4478 control.settings.x( position[0] ); 4479 control.settings.y( position[1] ); 4480 } ); 4481 4482 updateRadios = _.debounce( function() { 4483 var x, y, radioInput, inputValue; 4484 x = control.settings.x.get(); 4485 y = control.settings.y.get(); 4486 inputValue = String( x ) + ' ' + String( y ); 4487 radioInput = control.container.find( 'input[name="background-position"][value="' + inputValue + '"]' ); 4488 radioInput.trigger( 'click' ); 4489 } ); 4490 control.settings.x.bind( updateRadios ); 4491 control.settings.y.bind( updateRadios ); 4492 4493 updateRadios(); // Set initial UI. 4494 } 4495 } ); 4496 4497 /** 4498 * A control for selecting and cropping an image. 4499 * 4500 * @class wp.customize.CroppedImageControl 4501 * @augments wp.customize.MediaControl 4502 */ 4503 api.CroppedImageControl = api.MediaControl.extend(/** @lends wp.customize.CroppedImageControl.prototype */{ 4504 4505 /** 4506 * Open the media modal to the library state. 4507 */ 4508 openFrame: function( event ) { 4509 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4510 return; 4511 } 4512 4513 this.initFrame(); 4514 this.frame.setState( 'library' ).open(); 4515 }, 4516 4517 /** 4518 * Create a media modal select frame, and store it so the instance can be reused when needed. 4519 */ 4520 initFrame: function() { 4521 var l10n = _wpMediaViewsL10n; 4522 4523 this.frame = wp.media({ 4524 button: { 4525 text: l10n.select, 4526 close: false 4527 }, 4528 states: [ 4529 new wp.media.controller.Library({ 4530 title: this.params.button_labels.frame_title, 4531 library: wp.media.query({ type: 'image' }), 4532 multiple: false, 4533 date: false, 4534 priority: 20, 4535 suggestedWidth: this.params.width, 4536 suggestedHeight: this.params.height 4537 }), 4538 new wp.media.controller.CustomizeImageCropper({ 4539 imgSelectOptions: this.calculateImageSelectOptions, 4540 control: this 4541 }) 4542 ] 4543 }); 4544 4545 this.frame.on( 'select', this.onSelect, this ); 4546 this.frame.on( 'cropped', this.onCropped, this ); 4547 this.frame.on( 'skippedcrop', this.onSkippedCrop, this ); 4548 }, 4549 4550 /** 4551 * After an image is selected in the media modal, switch to the cropper 4552 * state if the image isn't the right size. 4553 */ 4554 onSelect: function() { 4555 var attachment = this.frame.state().get( 'selection' ).first().toJSON(); 4556 4557 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) { 4558 this.setImageFromAttachment( attachment ); 4559 this.frame.close(); 4560 } else { 4561 this.frame.setState( 'cropper' ); 4562 } 4563 }, 4564 4565 /** 4566 * After the image has been cropped, apply the cropped image data to the setting. 4567 * 4568 * @param {Object} croppedImage Cropped attachment data. 4569 */ 4570 onCropped: function( croppedImage ) { 4571 this.setImageFromAttachment( croppedImage ); 4572 }, 4573 4574 /** 4575 * Returns a set of options, computed from the attached image data and 4576 * control-specific data, to be fed to the imgAreaSelect plugin in 4577 * wp.media.view.Cropper. 4578 * 4579 * @param {wp.media.model.Attachment} attachment 4580 * @param {wp.media.controller.Cropper} controller 4581 * @return {Object} Options 4582 */ 4583 calculateImageSelectOptions: function( attachment, controller ) { 4584 var control = controller.get( 'control' ), 4585 flexWidth = !! parseInt( control.params.flex_width, 10 ), 4586 flexHeight = !! parseInt( control.params.flex_height, 10 ), 4587 realWidth = attachment.get( 'width' ), 4588 realHeight = attachment.get( 'height' ), 4589 xInit = parseInt( control.params.width, 10 ), 4590 yInit = parseInt( control.params.height, 10 ), 4591 ratio = xInit / yInit, 4592 xImg = xInit, 4593 yImg = yInit, 4594 x1, y1, imgSelectOptions; 4595 4596 controller.set( 'canSkipCrop', ! control.mustBeCropped( flexWidth, flexHeight, xInit, yInit, realWidth, realHeight ) ); 4597 4598 if ( realWidth / realHeight > ratio ) { 4599 yInit = realHeight; 4600 xInit = yInit * ratio; 4601 } else { 4602 xInit = realWidth; 4603 yInit = xInit / ratio; 4604 } 4605 4606 x1 = ( realWidth - xInit ) / 2; 4607 y1 = ( realHeight - yInit ) / 2; 4608 4609 imgSelectOptions = { 4610 handles: true, 4611 keys: true, 4612 instance: true, 4613 persistent: true, 4614 imageWidth: realWidth, 4615 imageHeight: realHeight, 4616 minWidth: xImg > xInit ? xInit : xImg, 4617 minHeight: yImg > yInit ? yInit : yImg, 4618 x1: x1, 4619 y1: y1, 4620 x2: xInit + x1, 4621 y2: yInit + y1 4622 }; 4623 4624 if ( flexHeight === false && flexWidth === false ) { 4625 imgSelectOptions.aspectRatio = xInit + ':' + yInit; 4626 } 4627 4628 if ( true === flexHeight ) { 4629 delete imgSelectOptions.minHeight; 4630 imgSelectOptions.maxWidth = realWidth; 4631 } 4632 4633 if ( true === flexWidth ) { 4634 delete imgSelectOptions.minWidth; 4635 imgSelectOptions.maxHeight = realHeight; 4636 } 4637 4638 return imgSelectOptions; 4639 }, 4640 4641 /** 4642 * Return whether the image must be cropped, based on required dimensions. 4643 * 4644 * @param {boolean} flexW 4645 * @param {boolean} flexH 4646 * @param {number} dstW 4647 * @param {number} dstH 4648 * @param {number} imgW 4649 * @param {number} imgH 4650 * @return {boolean} 4651 */ 4652 mustBeCropped: function( flexW, flexH, dstW, dstH, imgW, imgH ) { 4653 if ( true === flexW && true === flexH ) { 4654 return false; 4655 } 4656 4657 if ( true === flexW && dstH === imgH ) { 4658 return false; 4659 } 4660 4661 if ( true === flexH && dstW === imgW ) { 4662 return false; 4663 } 4664 4665 if ( dstW === imgW && dstH === imgH ) { 4666 return false; 4667 } 4668 4669 if ( imgW <= dstW ) { 4670 return false; 4671 } 4672 4673 return true; 4674 }, 4675 4676 /** 4677 * If cropping was skipped, apply the image data directly to the setting. 4678 */ 4679 onSkippedCrop: function() { 4680 var attachment = this.frame.state().get( 'selection' ).first().toJSON(); 4681 this.setImageFromAttachment( attachment ); 4682 }, 4683 4684 /** 4685 * Updates the setting and re-renders the control UI. 4686 * 4687 * @param {Object} attachment 4688 */ 4689 setImageFromAttachment: function( attachment ) { 4690 this.params.attachment = attachment; 4691 4692 // Set the Customizer setting; the callback takes care of rendering. 4693 this.setting( attachment.id ); 4694 } 4695 }); 4696 4697 /** 4698 * A control for selecting and cropping Site Icons. 4699 * 4700 * @class wp.customize.SiteIconControl 4701 * @augments wp.customize.CroppedImageControl 4702 */ 4703 api.SiteIconControl = api.CroppedImageControl.extend(/** @lends wp.customize.SiteIconControl.prototype */{ 4704 4705 /** 4706 * Create a media modal select frame, and store it so the instance can be reused when needed. 4707 */ 4708 initFrame: function() { 4709 var l10n = _wpMediaViewsL10n; 4710 4711 this.frame = wp.media({ 4712 button: { 4713 text: l10n.select, 4714 close: false 4715 }, 4716 states: [ 4717 new wp.media.controller.Library({ 4718 title: this.params.button_labels.frame_title, 4719 library: wp.media.query({ type: 'image' }), 4720 multiple: false, 4721 date: false, 4722 priority: 20, 4723 suggestedWidth: this.params.width, 4724 suggestedHeight: this.params.height 4725 }), 4726 new wp.media.controller.SiteIconCropper({ 4727 imgSelectOptions: this.calculateImageSelectOptions, 4728 control: this 4729 }) 4730 ] 4731 }); 4732 4733 this.frame.on( 'select', this.onSelect, this ); 4734 this.frame.on( 'cropped', this.onCropped, this ); 4735 this.frame.on( 'skippedcrop', this.onSkippedCrop, this ); 4736 }, 4737 4738 /** 4739 * After an image is selected in the media modal, switch to the cropper 4740 * state if the image isn't the right size. 4741 */ 4742 onSelect: function() { 4743 var attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4744 controller = this; 4745 4746 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) { 4747 wp.ajax.post( 'crop-image', { 4748 nonce: attachment.nonces.edit, 4749 id: attachment.id, 4750 context: 'site-icon', 4751 cropDetails: { 4752 x1: 0, 4753 y1: 0, 4754 width: this.params.width, 4755 height: this.params.height, 4756 dst_width: this.params.width, 4757 dst_height: this.params.height 4758 } 4759 } ).done( function( croppedImage ) { 4760 controller.setImageFromAttachment( croppedImage ); 4761 controller.frame.close(); 4762 } ).fail( function() { 4763 controller.frame.trigger('content:error:crop'); 4764 } ); 4765 } else { 4766 this.frame.setState( 'cropper' ); 4767 } 4768 }, 4769 4770 /** 4771 * Updates the setting and re-renders the control UI. 4772 * 4773 * @param {Object} attachment 4774 */ 4775 setImageFromAttachment: function( attachment ) { 4776 var sizes = [ 'site_icon-32', 'thumbnail', 'full' ], link, 4777 icon; 4778 4779 _.each( sizes, function( size ) { 4780 if ( ! icon && ! _.isUndefined ( attachment.sizes[ size ] ) ) { 4781 icon = attachment.sizes[ size ]; 4782 } 4783 } ); 4784 4785 this.params.attachment = attachment; 4786 4787 // Set the Customizer setting; the callback takes care of rendering. 4788 this.setting( attachment.id ); 4789 4790 if ( ! icon ) { 4791 return; 4792 } 4793 4794 // Update the icon in-browser. 4795 link = $( 'link[rel="icon"][sizes="32x32"]' ); 4796 link.attr( 'href', icon.url ); 4797 }, 4798 4799 /** 4800 * Called when the "Remove" link is clicked. Empties the setting. 4801 * 4802 * @param {Object} event jQuery Event object 4803 */ 4804 removeFile: function( event ) { 4805 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4806 return; 4807 } 4808 event.preventDefault(); 4809 4810 this.params.attachment = {}; 4811 this.setting( '' ); 4812 this.renderContent(); // Not bound to setting change when emptying. 4813 $( 'link[rel="icon"][sizes="32x32"]' ).attr( 'href', '/favicon.ico' ); // Set to default. 4814 } 4815 }); 4816 4817 /** 4818 * @class wp.customize.HeaderControl 4819 * @augments wp.customize.Control 4820 */ 4821 api.HeaderControl = api.Control.extend(/** @lends wp.customize.HeaderControl.prototype */{ 4822 ready: function() { 4823 this.btnRemove = $('#customize-control-header_image .actions .remove'); 4824 this.btnNew = $('#customize-control-header_image .actions .new'); 4825 4826 _.bindAll(this, 'openMedia', 'removeImage'); 4827 4828 this.btnNew.on( 'click', this.openMedia ); 4829 this.btnRemove.on( 'click', this.removeImage ); 4830 4831 api.HeaderTool.currentHeader = this.getInitialHeaderImage(); 4832 4833 new api.HeaderTool.CurrentView({ 4834 model: api.HeaderTool.currentHeader, 4835 el: '#customize-control-header_image .current .container' 4836 }); 4837 4838 new api.HeaderTool.ChoiceListView({ 4839 collection: api.HeaderTool.UploadsList = new api.HeaderTool.ChoiceList(), 4840 el: '#customize-control-header_image .choices .uploaded .list' 4841 }); 4842 4843 new api.HeaderTool.ChoiceListView({ 4844 collection: api.HeaderTool.DefaultsList = new api.HeaderTool.DefaultsList(), 4845 el: '#customize-control-header_image .choices .default .list' 4846 }); 4847 4848 api.HeaderTool.combinedList = api.HeaderTool.CombinedList = new api.HeaderTool.CombinedList([ 4849 api.HeaderTool.UploadsList, 4850 api.HeaderTool.DefaultsList 4851 ]); 4852 4853 // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme. 4854 wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on'; 4855 wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet; 4856 }, 4857 4858 /** 4859 * Returns a new instance of api.HeaderTool.ImageModel based on the currently 4860 * saved header image (if any). 4861 * 4862 * @since 4.2.0 4863 * 4864 * @return {Object} Options 4865 */ 4866 getInitialHeaderImage: function() { 4867 if ( ! api.get().header_image || ! api.get().header_image_data || _.contains( [ 'remove-header', 'random-default-image', 'random-uploaded-image' ], api.get().header_image ) ) { 4868 return new api.HeaderTool.ImageModel(); 4869 } 4870 4871 // Get the matching uploaded image object. 4872 var currentHeaderObject = _.find( _wpCustomizeHeader.uploads, function( imageObj ) { 4873 return ( imageObj.attachment_id === api.get().header_image_data.attachment_id ); 4874 } ); 4875 // Fall back to raw current header image. 4876 if ( ! currentHeaderObject ) { 4877 currentHeaderObject = { 4878 url: api.get().header_image, 4879 thumbnail_url: api.get().header_image, 4880 attachment_id: api.get().header_image_data.attachment_id 4881 }; 4882 } 4883 4884 return new api.HeaderTool.ImageModel({ 4885 header: currentHeaderObject, 4886 choice: currentHeaderObject.url.split( '/' ).pop() 4887 }); 4888 }, 4889 4890 /** 4891 * Returns a set of options, computed from the attached image data and 4892 * theme-specific data, to be fed to the imgAreaSelect plugin in 4893 * wp.media.view.Cropper. 4894 * 4895 * @param {wp.media.model.Attachment} attachment 4896 * @param {wp.media.controller.Cropper} controller 4897 * @return {Object} Options 4898 */ 4899 calculateImageSelectOptions: function(attachment, controller) { 4900 var xInit = parseInt(_wpCustomizeHeader.data.width, 10), 4901 yInit = parseInt(_wpCustomizeHeader.data.height, 10), 4902 flexWidth = !! parseInt(_wpCustomizeHeader.data['flex-width'], 10), 4903 flexHeight = !! parseInt(_wpCustomizeHeader.data['flex-height'], 10), 4904 ratio, xImg, yImg, realHeight, realWidth, 4905 imgSelectOptions; 4906 4907 realWidth = attachment.get('width'); 4908 realHeight = attachment.get('height'); 4909 4910 this.headerImage = new api.HeaderTool.ImageModel(); 4911 this.headerImage.set({ 4912 themeWidth: xInit, 4913 themeHeight: yInit, 4914 themeFlexWidth: flexWidth, 4915 themeFlexHeight: flexHeight, 4916 imageWidth: realWidth, 4917 imageHeight: realHeight 4918 }); 4919 4920 controller.set( 'canSkipCrop', ! this.headerImage.shouldBeCropped() ); 4921 4922 ratio = xInit / yInit; 4923 xImg = realWidth; 4924 yImg = realHeight; 4925 4926 if ( xImg / yImg > ratio ) { 4927 yInit = yImg; 4928 xInit = yInit * ratio; 4929 } else { 4930 xInit = xImg; 4931 yInit = xInit / ratio; 4932 } 4933 4934 imgSelectOptions = { 4935 handles: true, 4936 keys: true, 4937 instance: true, 4938 persistent: true, 4939 imageWidth: realWidth, 4940 imageHeight: realHeight, 4941 x1: 0, 4942 y1: 0, 4943 x2: xInit, 4944 y2: yInit 4945 }; 4946 4947 if (flexHeight === false && flexWidth === false) { 4948 imgSelectOptions.aspectRatio = xInit + ':' + yInit; 4949 } 4950 if (flexHeight === false ) { 4951 imgSelectOptions.maxHeight = yInit; 4952 } 4953 if (flexWidth === false ) { 4954 imgSelectOptions.maxWidth = xInit; 4955 } 4956 4957 return imgSelectOptions; 4958 }, 4959 4960 /** 4961 * Sets up and opens the Media Manager in order to select an image. 4962 * Depending on both the size of the image and the properties of the 4963 * current theme, a cropping step after selection may be required or 4964 * skippable. 4965 * 4966 * @param {event} event 4967 */ 4968 openMedia: function(event) { 4969 var l10n = _wpMediaViewsL10n; 4970 4971 event.preventDefault(); 4972 4973 this.frame = wp.media({ 4974 button: { 4975 text: l10n.selectAndCrop, 4976 close: false 4977 }, 4978 states: [ 4979 new wp.media.controller.Library({ 4980 title: l10n.chooseImage, 4981 library: wp.media.query({ type: 'image' }), 4982 multiple: false, 4983 date: false, 4984 priority: 20, 4985 suggestedWidth: _wpCustomizeHeader.data.width, 4986 suggestedHeight: _wpCustomizeHeader.data.height 4987 }), 4988 new wp.media.controller.Cropper({ 4989 imgSelectOptions: this.calculateImageSelectOptions 4990 }) 4991 ] 4992 }); 4993 4994 this.frame.on('select', this.onSelect, this); 4995 this.frame.on('cropped', this.onCropped, this); 4996 this.frame.on('skippedcrop', this.onSkippedCrop, this); 4997 4998 this.frame.open(); 4999 }, 5000 5001 /** 5002 * After an image is selected in the media modal, 5003 * switch to the cropper state. 5004 */ 5005 onSelect: function() { 5006 this.frame.setState('cropper'); 5007 }, 5008 5009 /** 5010 * After the image has been cropped, apply the cropped image data to the setting. 5011 * 5012 * @param {Object} croppedImage Cropped attachment data. 5013 */ 5014 onCropped: function(croppedImage) { 5015 var url = croppedImage.url, 5016 attachmentId = croppedImage.attachment_id, 5017 w = croppedImage.width, 5018 h = croppedImage.height; 5019 this.setImageFromURL(url, attachmentId, w, h); 5020 }, 5021 5022 /** 5023 * If cropping was skipped, apply the image data directly to the setting. 5024 * 5025 * @param {Object} selection 5026 */ 5027 onSkippedCrop: function(selection) { 5028 var url = selection.get('url'), 5029 w = selection.get('width'), 5030 h = selection.get('height'); 5031 this.setImageFromURL(url, selection.id, w, h); 5032 }, 5033 5034 /** 5035 * Creates a new wp.customize.HeaderTool.ImageModel from provided 5036 * header image data and inserts it into the user-uploaded headers 5037 * collection. 5038 * 5039 * @param {string} url 5040 * @param {number} attachmentId 5041 * @param {number} width 5042 * @param {number} height 5043 */ 5044 setImageFromURL: function(url, attachmentId, width, height) { 5045 var choice, data = {}; 5046 5047 data.url = url; 5048 data.thumbnail_url = url; 5049 data.timestamp = _.now(); 5050 5051 if (attachmentId) { 5052 data.attachment_id = attachmentId; 5053 } 5054 5055 if (width) { 5056 data.width = width; 5057 } 5058 5059 if (height) { 5060 data.height = height; 5061 } 5062 5063 choice = new api.HeaderTool.ImageModel({ 5064 header: data, 5065 choice: url.split('/').pop() 5066 }); 5067 api.HeaderTool.UploadsList.add(choice); 5068 api.HeaderTool.currentHeader.set(choice.toJSON()); 5069 choice.save(); 5070 choice.importImage(); 5071 }, 5072 5073 /** 5074 * Triggers the necessary events to deselect an image which was set as 5075 * the currently selected one. 5076 */ 5077 removeImage: function() { 5078 api.HeaderTool.currentHeader.trigger('hide'); 5079 api.HeaderTool.CombinedList.trigger('control:removeImage'); 5080 } 5081 5082 }); 5083 5084 /** 5085 * wp.customize.ThemeControl 5086 * 5087 * @class wp.customize.ThemeControl 5088 * @augments wp.customize.Control 5089 */ 5090 api.ThemeControl = api.Control.extend(/** @lends wp.customize.ThemeControl.prototype */{ 5091 5092 touchDrag: false, 5093 screenshotRendered: false, 5094 5095 /** 5096 * @since 4.2.0 5097 */ 5098 ready: function() { 5099 var control = this, panel = api.panel( 'themes' ); 5100 5101 function disableSwitchButtons() { 5102 return ! panel.canSwitchTheme( control.params.theme.id ); 5103 } 5104 5105 // Temporary special function since supplying SFTP credentials does not work yet. See #42184. 5106 function disableInstallButtons() { 5107 return disableSwitchButtons() || false === api.settings.theme._canInstall || true === api.settings.theme._filesystemCredentialsNeeded; 5108 } 5109 function updateButtons() { 5110 control.container.find( 'button.preview, button.preview-theme' ).toggleClass( 'disabled', disableSwitchButtons() ); 5111 control.container.find( 'button.theme-install' ).toggleClass( 'disabled', disableInstallButtons() ); 5112 } 5113 5114 api.state( 'selectedChangesetStatus' ).bind( updateButtons ); 5115 api.state( 'changesetStatus' ).bind( updateButtons ); 5116 updateButtons(); 5117 5118 control.container.on( 'touchmove', '.theme', function() { 5119 control.touchDrag = true; 5120 }); 5121 5122 // Bind details view trigger. 5123 control.container.on( 'click keydown touchend', '.theme', function( event ) { 5124 var section; 5125 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 5126 return; 5127 } 5128 5129 // Bail if the user scrolled on a touch device. 5130 if ( control.touchDrag === true ) { 5131 return control.touchDrag = false; 5132 } 5133 5134 // Prevent the modal from showing when the user clicks the action button. 5135 if ( $( event.target ).is( '.theme-actions .button, .update-theme' ) ) { 5136 return; 5137 } 5138 5139 event.preventDefault(); // Keep this AFTER the key filter above. 5140 section = api.section( control.section() ); 5141 section.showDetails( control.params.theme, function() { 5142 5143 // Temporary special function since supplying SFTP credentials does not work yet. See #42184. 5144 if ( api.settings.theme._filesystemCredentialsNeeded ) { 5145 section.overlay.find( '.theme-actions .delete-theme' ).remove(); 5146 } 5147 } ); 5148 }); 5149 5150 control.container.on( 'render-screenshot', function() { 5151 var $screenshot = $( this ).find( 'img' ), 5152 source = $screenshot.data( 'src' ); 5153 5154 if ( source ) { 5155 $screenshot.attr( 'src', source ); 5156 } 5157 control.screenshotRendered = true; 5158 }); 5159 }, 5160 5161 /** 5162 * Show or hide the theme based on the presence of the term in the title, description, tags, and author. 5163 * 5164 * @since 4.2.0 5165 * @param {Array} terms - An array of terms to search for. 5166 * @return {boolean} Whether a theme control was activated or not. 5167 */ 5168 filter: function( terms ) { 5169 var control = this, 5170 matchCount = 0, 5171 haystack = control.params.theme.name + ' ' + 5172 control.params.theme.description + ' ' + 5173 control.params.theme.tags + ' ' + 5174 control.params.theme.author + ' '; 5175 haystack = haystack.toLowerCase().replace( '-', ' ' ); 5176 5177 // Back-compat for behavior in WordPress 4.2.0 to 4.8.X. 5178 if ( ! _.isArray( terms ) ) { 5179 terms = [ terms ]; 5180 } 5181 5182 // Always give exact name matches highest ranking. 5183 if ( control.params.theme.name.toLowerCase() === terms.join( ' ' ) ) { 5184 matchCount = 100; 5185 } else { 5186 5187 // Search for and weight (by 10) complete term matches. 5188 matchCount = matchCount + 10 * ( haystack.split( terms.join( ' ' ) ).length - 1 ); 5189 5190 // Search for each term individually (as whole-word and partial match) and sum weighted match counts. 5191 _.each( terms, function( term ) { 5192 matchCount = matchCount + 2 * ( haystack.split( term + ' ' ).length - 1 ); // Whole-word, double-weighted. 5193 matchCount = matchCount + haystack.split( term ).length - 1; // Partial word, to minimize empty intermediate searches while typing. 5194 }); 5195 5196 // Upper limit on match ranking. 5197 if ( matchCount > 99 ) { 5198 matchCount = 99; 5199 } 5200 } 5201 5202 if ( 0 !== matchCount ) { 5203 control.activate(); 5204 control.params.priority = 101 - matchCount; // Sort results by match count. 5205 return true; 5206 } else { 5207 control.deactivate(); // Hide control. 5208 control.params.priority = 101; 5209 return false; 5210 } 5211 }, 5212 5213 /** 5214 * Rerender the theme from its JS template with the installed type. 5215 * 5216 * @since 4.9.0 5217 * 5218 * @return {void} 5219 */ 5220 rerenderAsInstalled: function( installed ) { 5221 var control = this, section; 5222 if ( installed ) { 5223 control.params.theme.type = 'installed'; 5224 } else { 5225 section = api.section( control.params.section ); 5226 control.params.theme.type = section.params.action; 5227 } 5228 control.renderContent(); // Replaces existing content. 5229 control.container.trigger( 'render-screenshot' ); 5230 } 5231 }); 5232 5233 /** 5234 * Class wp.customize.CodeEditorControl 5235 * 5236 * @since 4.9.0 5237 * 5238 * @class wp.customize.CodeEditorControl 5239 * @augments wp.customize.Control 5240 */ 5241 api.CodeEditorControl = api.Control.extend(/** @lends wp.customize.CodeEditorControl.prototype */{ 5242 5243 /** 5244 * Initialize. 5245 * 5246 * @since 4.9.0 5247 * @param {string} id - Unique identifier for the control instance. 5248 * @param {Object} options - Options hash for the control instance. 5249 * @return {void} 5250 */ 5251 initialize: function( id, options ) { 5252 var control = this; 5253 control.deferred = _.extend( control.deferred || {}, { 5254 codemirror: $.Deferred() 5255 } ); 5256 api.Control.prototype.initialize.call( control, id, options ); 5257 5258 // Note that rendering is debounced so the props will be used when rendering happens after add event. 5259 control.notifications.bind( 'add', function( notification ) { 5260 5261 // Skip if control notification is not from setting csslint_error notification. 5262 if ( notification.code !== control.setting.id + ':csslint_error' ) { 5263 return; 5264 } 5265 5266 // Customize the template and behavior of csslint_error notifications. 5267 notification.templateId = 'customize-code-editor-lint-error-notification'; 5268 notification.render = (function( render ) { 5269 return function() { 5270 var li = render.call( this ); 5271 li.find( 'input[type=checkbox]' ).on( 'click', function() { 5272 control.setting.notifications.remove( 'csslint_error' ); 5273 } ); 5274 return li; 5275 }; 5276 })( notification.render ); 5277 } ); 5278 }, 5279 5280 /** 5281 * Initialize the editor when the containing section is ready and expanded. 5282 * 5283 * @since 4.9.0 5284 * @return {void} 5285 */ 5286 ready: function() { 5287 var control = this; 5288 if ( ! control.section() ) { 5289 control.initEditor(); 5290 return; 5291 } 5292 5293 // Wait to initialize editor until section is embedded and expanded. 5294 api.section( control.section(), function( section ) { 5295 section.deferred.embedded.done( function() { 5296 var onceExpanded; 5297 if ( section.expanded() ) { 5298 control.initEditor(); 5299 } else { 5300 onceExpanded = function( isExpanded ) { 5301 if ( isExpanded ) { 5302 control.initEditor(); 5303 section.expanded.unbind( onceExpanded ); 5304 } 5305 }; 5306 section.expanded.bind( onceExpanded ); 5307 } 5308 } ); 5309 } ); 5310 }, 5311 5312 /** 5313 * Initialize editor. 5314 * 5315 * @since 4.9.0 5316 * @return {void} 5317 */ 5318 initEditor: function() { 5319 var control = this, element, editorSettings = false; 5320 5321 // Obtain editorSettings for instantiation. 5322 if ( wp.codeEditor && ( _.isUndefined( control.params.editor_settings ) || false !== control.params.editor_settings ) ) { 5323 5324 // Obtain default editor settings. 5325 editorSettings = wp.codeEditor.defaultSettings ? _.clone( wp.codeEditor.defaultSettings ) : {}; 5326 editorSettings.codemirror = _.extend( 5327 {}, 5328 editorSettings.codemirror, 5329 { 5330 indentUnit: 2, 5331 tabSize: 2 5332 } 5333 ); 5334 5335 // Merge editor_settings param on top of defaults. 5336 if ( _.isObject( control.params.editor_settings ) ) { 5337 _.each( control.params.editor_settings, function( value, key ) { 5338 if ( _.isObject( value ) ) { 5339 editorSettings[ key ] = _.extend( 5340 {}, 5341 editorSettings[ key ], 5342 value 5343 ); 5344 } 5345 } ); 5346 } 5347 } 5348 5349 element = new api.Element( control.container.find( 'textarea' ) ); 5350 control.elements.push( element ); 5351 element.sync( control.setting ); 5352 element.set( control.setting() ); 5353 5354 if ( editorSettings ) { 5355 control.initSyntaxHighlightingEditor( editorSettings ); 5356 } else { 5357 control.initPlainTextareaEditor(); 5358 } 5359 }, 5360 5361 /** 5362 * Make sure editor gets focused when control is focused. 5363 * 5364 * @since 4.9.0 5365 * @param {Object} [params] - Focus params. 5366 * @param {Function} [params.completeCallback] - Function to call when expansion is complete. 5367 * @return {void} 5368 */ 5369 focus: function( params ) { 5370 var control = this, extendedParams = _.extend( {}, params ), originalCompleteCallback; 5371 originalCompleteCallback = extendedParams.completeCallback; 5372 extendedParams.completeCallback = function() { 5373 if ( originalCompleteCallback ) { 5374 originalCompleteCallback(); 5375 } 5376 if ( control.editor ) { 5377 control.editor.codemirror.focus(); 5378 } 5379 }; 5380 api.Control.prototype.focus.call( control, extendedParams ); 5381 }, 5382 5383 /** 5384 * Initialize syntax-highlighting editor. 5385 * 5386 * @since 4.9.0 5387 * @param {Object} codeEditorSettings - Code editor settings. 5388 * @return {void} 5389 */ 5390 initSyntaxHighlightingEditor: function( codeEditorSettings ) { 5391 var control = this, $textarea = control.container.find( 'textarea' ), settings, suspendEditorUpdate = false; 5392 5393 settings = _.extend( {}, codeEditorSettings, { 5394 onTabNext: _.bind( control.onTabNext, control ), 5395 onTabPrevious: _.bind( control.onTabPrevious, control ), 5396 onUpdateErrorNotice: _.bind( control.onUpdateErrorNotice, control ) 5397 }); 5398 5399 control.editor = wp.codeEditor.initialize( $textarea, settings ); 5400 5401 // Improve the editor accessibility. 5402 $( control.editor.codemirror.display.lineDiv ) 5403 .attr({ 5404 role: 'textbox', 5405 'aria-multiline': 'true', 5406 'aria-label': control.params.label, 5407 'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4' 5408 }); 5409 5410 // Focus the editor when clicking on its label. 5411 control.container.find( 'label' ).on( 'click', function() { 5412 control.editor.codemirror.focus(); 5413 }); 5414 5415 /* 5416 * When the CodeMirror instance changes, mirror to the textarea, 5417 * where we have our "true" change event handler bound. 5418 */ 5419 control.editor.codemirror.on( 'change', function( codemirror ) { 5420 suspendEditorUpdate = true; 5421 $textarea.val( codemirror.getValue() ).trigger( 'change' ); 5422 suspendEditorUpdate = false; 5423 }); 5424 5425 // Update CodeMirror when the setting is changed by another plugin. 5426 control.setting.bind( function( value ) { 5427 if ( ! suspendEditorUpdate ) { 5428 control.editor.codemirror.setValue( value ); 5429 } 5430 }); 5431 5432 // Prevent collapsing section when hitting Esc to tab out of editor. 5433 control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) { 5434 var escKeyCode = 27; 5435 if ( escKeyCode === event.keyCode ) { 5436 event.stopPropagation(); 5437 } 5438 }); 5439 5440 control.deferred.codemirror.resolveWith( control, [ control.editor.codemirror ] ); 5441 }, 5442 5443 /** 5444 * Handle tabbing to the field after the editor. 5445 * 5446 * @since 4.9.0 5447 * @return {void} 5448 */ 5449 onTabNext: function onTabNext() { 5450 var control = this, controls, controlIndex, section; 5451 section = api.section( control.section() ); 5452 controls = section.controls(); 5453 controlIndex = controls.indexOf( control ); 5454 if ( controls.length === controlIndex + 1 ) { 5455 $( '#customize-footer-actions .collapse-sidebar' ).trigger( 'focus' ); 5456 } else { 5457 controls[ controlIndex + 1 ].container.find( ':focusable:first' ).focus(); 5458 } 5459 }, 5460 5461 /** 5462 * Handle tabbing to the field before the editor. 5463 * 5464 * @since 4.9.0 5465 * @return {void} 5466 */ 5467 onTabPrevious: function onTabPrevious() { 5468 var control = this, controls, controlIndex, section; 5469 section = api.section( control.section() ); 5470 controls = section.controls(); 5471 controlIndex = controls.indexOf( control ); 5472 if ( 0 === controlIndex ) { 5473 section.contentContainer.find( '.customize-section-title .customize-help-toggle, .customize-section-title .customize-section-description.open .section-description-close' ).last().focus(); 5474 } else { 5475 controls[ controlIndex - 1 ].contentContainer.find( ':focusable:first' ).focus(); 5476 } 5477 }, 5478 5479 /** 5480 * Update error notice. 5481 * 5482 * @since 4.9.0 5483 * @param {Array} errorAnnotations - Error annotations. 5484 * @return {void} 5485 */ 5486 onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) { 5487 var control = this, message; 5488 control.setting.notifications.remove( 'csslint_error' ); 5489 5490 if ( 0 !== errorAnnotations.length ) { 5491 if ( 1 === errorAnnotations.length ) { 5492 message = api.l10n.customCssError.singular.replace( '%d', '1' ); 5493 } else { 5494 message = api.l10n.customCssError.plural.replace( '%d', String( errorAnnotations.length ) ); 5495 } 5496 control.setting.notifications.add( new api.Notification( 'csslint_error', { 5497 message: message, 5498 type: 'error' 5499 } ) ); 5500 } 5501 }, 5502 5503 /** 5504 * Initialize plain-textarea editor when syntax highlighting is disabled. 5505 * 5506 * @since 4.9.0 5507 * @return {void} 5508 */ 5509 initPlainTextareaEditor: function() { 5510 var control = this, $textarea = control.container.find( 'textarea' ), textarea = $textarea[0]; 5511 5512 $textarea.on( 'blur', function onBlur() { 5513 $textarea.data( 'next-tab-blurs', false ); 5514 } ); 5515 5516 $textarea.on( 'keydown', function onKeydown( event ) { 5517 var selectionStart, selectionEnd, value, tabKeyCode = 9, escKeyCode = 27; 5518 5519 if ( escKeyCode === event.keyCode ) { 5520 if ( ! $textarea.data( 'next-tab-blurs' ) ) { 5521 $textarea.data( 'next-tab-blurs', true ); 5522 event.stopPropagation(); // Prevent collapsing the section. 5523 } 5524 return; 5525 } 5526 5527 // Short-circuit if tab key is not being pressed or if a modifier key *is* being pressed. 5528 if ( tabKeyCode !== event.keyCode || event.ctrlKey || event.altKey || event.shiftKey ) { 5529 return; 5530 } 5531 5532 // Prevent capturing Tab characters if Esc was pressed. 5533 if ( $textarea.data( 'next-tab-blurs' ) ) { 5534 return; 5535 } 5536 5537 selectionStart = textarea.selectionStart; 5538 selectionEnd = textarea.selectionEnd; 5539 value = textarea.value; 5540 5541 if ( selectionStart >= 0 ) { 5542 textarea.value = value.substring( 0, selectionStart ).concat( '\t', value.substring( selectionEnd ) ); 5543 $textarea.selectionStart = textarea.selectionEnd = selectionStart + 1; 5544 } 5545 5546 event.stopPropagation(); 5547 event.preventDefault(); 5548 }); 5549 5550 control.deferred.codemirror.rejectWith( control ); 5551 } 5552 }); 5553 5554 /** 5555 * Class wp.customize.DateTimeControl. 5556 * 5557 * @since 4.9.0 5558 * @class wp.customize.DateTimeControl 5559 * @augments wp.customize.Control 5560 */ 5561 api.DateTimeControl = api.Control.extend(/** @lends wp.customize.DateTimeControl.prototype */{ 5562 5563 /** 5564 * Initialize behaviors. 5565 * 5566 * @since 4.9.0 5567 * @return {void} 5568 */ 5569 ready: function ready() { 5570 var control = this; 5571 5572 control.inputElements = {}; 5573 control.invalidDate = false; 5574 5575 _.bindAll( control, 'populateSetting', 'updateDaysForMonth', 'populateDateInputs' ); 5576 5577 if ( ! control.setting ) { 5578 throw new Error( 'Missing setting' ); 5579 } 5580 5581 control.container.find( '.date-input' ).each( function() { 5582 var input = $( this ), component, element; 5583 component = input.data( 'component' ); 5584 element = new api.Element( input ); 5585 control.inputElements[ component ] = element; 5586 control.elements.push( element ); 5587 5588 // Add invalid date error once user changes (and has blurred the input). 5589 input.on( 'change', function() { 5590 if ( control.invalidDate ) { 5591 control.notifications.add( new api.Notification( 'invalid_date', { 5592 message: api.l10n.invalidDate 5593 } ) ); 5594 } 5595 } ); 5596 5597 // Remove the error immediately after validity change. 5598 input.on( 'input', _.debounce( function() { 5599 if ( ! control.invalidDate ) { 5600 control.notifications.remove( 'invalid_date' ); 5601 } 5602 } ) ); 5603 5604 // Add zero-padding when blurring field. 5605 input.on( 'blur', _.debounce( function() { 5606 if ( ! control.invalidDate ) { 5607 control.populateDateInputs(); 5608 } 5609 } ) ); 5610 } ); 5611 5612 control.inputElements.month.bind( control.updateDaysForMonth ); 5613 control.inputElements.year.bind( control.updateDaysForMonth ); 5614 control.populateDateInputs(); 5615 control.setting.bind( control.populateDateInputs ); 5616 5617 // Start populating setting after inputs have been populated. 5618 _.each( control.inputElements, function( element ) { 5619 element.bind( control.populateSetting ); 5620 } ); 5621 }, 5622 5623 /** 5624 * Parse datetime string. 5625 * 5626 * @since 4.9.0 5627 * 5628 * @param {string} datetime - Date/Time string. Accepts Y-m-d[ H:i[:s]] format. 5629 * @return {Object|null} Returns object containing date components or null if parse error. 5630 */ 5631 parseDateTime: function parseDateTime( datetime ) { 5632 var control = this, matches, date, midDayHour = 12; 5633 5634 if ( datetime ) { 5635 matches = datetime.match( /^(\d\d\d\d)-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/ ); 5636 } 5637 5638 if ( ! matches ) { 5639 return null; 5640 } 5641 5642 matches.shift(); 5643 5644 date = { 5645 year: matches.shift(), 5646 month: matches.shift(), 5647 day: matches.shift(), 5648 hour: matches.shift() || '00', 5649 minute: matches.shift() || '00', 5650 second: matches.shift() || '00' 5651 }; 5652 5653 if ( control.params.includeTime && control.params.twelveHourFormat ) { 5654 date.hour = parseInt( date.hour, 10 ); 5655 date.meridian = date.hour >= midDayHour ? 'pm' : 'am'; 5656 date.hour = date.hour % midDayHour ? String( date.hour % midDayHour ) : String( midDayHour ); 5657 delete date.second; // @todo Why only if twelveHourFormat? 5658 } 5659 5660 return date; 5661 }, 5662 5663 /** 5664 * Validates if input components have valid date and time. 5665 * 5666 * @since 4.9.0 5667 * @return {boolean} If date input fields has error. 5668 */ 5669 validateInputs: function validateInputs() { 5670 var control = this, components, validityInput; 5671 5672 control.invalidDate = false; 5673 5674 components = [ 'year', 'day' ]; 5675 if ( control.params.includeTime ) { 5676 components.push( 'hour', 'minute' ); 5677 } 5678 5679 _.find( components, function( component ) { 5680 var element, max, min, value; 5681 5682 element = control.inputElements[ component ]; 5683 validityInput = element.element.get( 0 ); 5684 max = parseInt( element.element.attr( 'max' ), 10 ); 5685 min = parseInt( element.element.attr( 'min' ), 10 ); 5686 value = parseInt( element(), 10 ); 5687 control.invalidDate = isNaN( value ) || value > max || value < min; 5688 5689 if ( ! control.invalidDate ) { 5690 validityInput.setCustomValidity( '' ); 5691 } 5692 5693 return control.invalidDate; 5694 } ); 5695 5696 if ( control.inputElements.meridian && ! control.invalidDate ) { 5697 validityInput = control.inputElements.meridian.element.get( 0 ); 5698 if ( 'am' !== control.inputElements.meridian.get() && 'pm' !== control.inputElements.meridian.get() ) { 5699 control.invalidDate = true; 5700 } else { 5701 validityInput.setCustomValidity( '' ); 5702 } 5703 } 5704 5705 if ( control.invalidDate ) { 5706 validityInput.setCustomValidity( api.l10n.invalidValue ); 5707 } else { 5708 validityInput.setCustomValidity( '' ); 5709 } 5710 if ( ! control.section() || api.section.has( control.section() ) && api.section( control.section() ).expanded() ) { 5711 _.result( validityInput, 'reportValidity' ); 5712 } 5713 5714 return control.invalidDate; 5715 }, 5716 5717 /** 5718 * Updates number of days according to the month and year selected. 5719 * 5720 * @since 4.9.0 5721 * @return {void} 5722 */ 5723 updateDaysForMonth: function updateDaysForMonth() { 5724 var control = this, daysInMonth, year, month, day; 5725 5726 month = parseInt( control.inputElements.month(), 10 ); 5727 year = parseInt( control.inputElements.year(), 10 ); 5728 day = parseInt( control.inputElements.day(), 10 ); 5729 5730 if ( month && year ) { 5731 daysInMonth = new Date( year, month, 0 ).getDate(); 5732 control.inputElements.day.element.attr( 'max', daysInMonth ); 5733 5734 if ( day > daysInMonth ) { 5735 control.inputElements.day( String( daysInMonth ) ); 5736 } 5737 } 5738 }, 5739 5740 /** 5741 * Populate setting value from the inputs. 5742 * 5743 * @since 4.9.0 5744 * @return {boolean} If setting updated. 5745 */ 5746 populateSetting: function populateSetting() { 5747 var control = this, date; 5748 5749 if ( control.validateInputs() || ! control.params.allowPastDate && ! control.isFutureDate() ) { 5750 return false; 5751 } 5752 5753 date = control.convertInputDateToString(); 5754 control.setting.set( date ); 5755 return true; 5756 }, 5757 5758 /** 5759 * Converts input values to string in Y-m-d H:i:s format. 5760 * 5761 * @since 4.9.0 5762 * @return {string} Date string. 5763 */ 5764 convertInputDateToString: function convertInputDateToString() { 5765 var control = this, date = '', dateFormat, hourInTwentyFourHourFormat, 5766 getElementValue, pad; 5767 5768 pad = function( number, padding ) { 5769 var zeros; 5770 if ( String( number ).length < padding ) { 5771 zeros = padding - String( number ).length; 5772 number = Math.pow( 10, zeros ).toString().substr( 1 ) + String( number ); 5773 } 5774 return number; 5775 }; 5776 5777 getElementValue = function( component ) { 5778 var value = parseInt( control.inputElements[ component ].get(), 10 ); 5779 5780 if ( _.contains( [ 'month', 'day', 'hour', 'minute' ], component ) ) { 5781 value = pad( value, 2 ); 5782 } else if ( 'year' === component ) { 5783 value = pad( value, 4 ); 5784 } 5785 return value; 5786 }; 5787 5788 dateFormat = [ 'year', '-', 'month', '-', 'day' ]; 5789 if ( control.params.includeTime ) { 5790 hourInTwentyFourHourFormat = control.inputElements.meridian ? control.convertHourToTwentyFourHourFormat( control.inputElements.hour(), control.inputElements.meridian() ) : control.inputElements.hour(); 5791 dateFormat = dateFormat.concat( [ ' ', pad( hourInTwentyFourHourFormat, 2 ), ':', 'minute', ':', '00' ] ); 5792 } 5793 5794 _.each( dateFormat, function( component ) { 5795 date += control.inputElements[ component ] ? getElementValue( component ) : component; 5796 } ); 5797 5798 return date; 5799 }, 5800 5801 /** 5802 * Check if the date is in the future. 5803 * 5804 * @since 4.9.0 5805 * @return {boolean} True if future date. 5806 */ 5807 isFutureDate: function isFutureDate() { 5808 var control = this; 5809 return 0 < api.utils.getRemainingTime( control.convertInputDateToString() ); 5810 }, 5811 5812 /** 5813 * Convert hour in twelve hour format to twenty four hour format. 5814 * 5815 * @since 4.9.0 5816 * @param {string} hourInTwelveHourFormat - Hour in twelve hour format. 5817 * @param {string} meridian - Either 'am' or 'pm'. 5818 * @return {string} Hour in twenty four hour format. 5819 */ 5820 convertHourToTwentyFourHourFormat: function convertHour( hourInTwelveHourFormat, meridian ) { 5821 var hourInTwentyFourHourFormat, hour, midDayHour = 12; 5822 5823 hour = parseInt( hourInTwelveHourFormat, 10 ); 5824 if ( isNaN( hour ) ) { 5825 return ''; 5826 } 5827 5828 if ( 'pm' === meridian && hour < midDayHour ) { 5829 hourInTwentyFourHourFormat = hour + midDayHour; 5830 } else if ( 'am' === meridian && midDayHour === hour ) { 5831 hourInTwentyFourHourFormat = hour - midDayHour; 5832 } else { 5833 hourInTwentyFourHourFormat = hour; 5834 } 5835 5836 return String( hourInTwentyFourHourFormat ); 5837 }, 5838 5839 /** 5840 * Populates date inputs in date fields. 5841 * 5842 * @since 4.9.0 5843 * @return {boolean} Whether the inputs were populated. 5844 */ 5845 populateDateInputs: function populateDateInputs() { 5846 var control = this, parsed; 5847 5848 parsed = control.parseDateTime( control.setting.get() ); 5849 5850 if ( ! parsed ) { 5851 return false; 5852 } 5853 5854 _.each( control.inputElements, function( element, component ) { 5855 var value = parsed[ component ]; // This will be zero-padded string. 5856 5857 // Set month and meridian regardless of focused state since they are dropdowns. 5858 if ( 'month' === component || 'meridian' === component ) { 5859 5860 // Options in dropdowns are not zero-padded. 5861 value = value.replace( /^0/, '' ); 5862 5863 element.set( value ); 5864 } else { 5865 5866 value = parseInt( value, 10 ); 5867 if ( ! element.element.is( document.activeElement ) ) { 5868 5869 // Populate element with zero-padded value if not focused. 5870 element.set( parsed[ component ] ); 5871 } else if ( value !== parseInt( element(), 10 ) ) { 5872 5873 // Forcibly update the value if its underlying value changed, regardless of zero-padding. 5874 element.set( String( value ) ); 5875 } 5876 } 5877 } ); 5878 5879 return true; 5880 }, 5881 5882 /** 5883 * Toggle future date notification for date control. 5884 * 5885 * @since 4.9.0 5886 * @param {boolean} notify Add or remove the notification. 5887 * @return {wp.customize.DateTimeControl} 5888 */ 5889 toggleFutureDateNotification: function toggleFutureDateNotification( notify ) { 5890 var control = this, notificationCode, notification; 5891 5892 notificationCode = 'not_future_date'; 5893 5894 if ( notify ) { 5895 notification = new api.Notification( notificationCode, { 5896 type: 'error', 5897 message: api.l10n.futureDateError 5898 } ); 5899 control.notifications.add( notification ); 5900 } else { 5901 control.notifications.remove( notificationCode ); 5902 } 5903 5904 return control; 5905 } 5906 }); 5907 5908 /** 5909 * Class PreviewLinkControl. 5910 * 5911 * @since 4.9.0 5912 * @class wp.customize.PreviewLinkControl 5913 * @augments wp.customize.Control 5914 */ 5915 api.PreviewLinkControl = api.Control.extend(/** @lends wp.customize.PreviewLinkControl.prototype */{ 5916 5917 defaults: _.extend( {}, api.Control.prototype.defaults, { 5918 templateId: 'customize-preview-link-control' 5919 } ), 5920 5921 /** 5922 * Initialize behaviors. 5923 * 5924 * @since 4.9.0 5925 * @return {void} 5926 */ 5927 ready: function ready() { 5928 var control = this, element, component, node, url, input, button; 5929 5930 _.bindAll( control, 'updatePreviewLink' ); 5931 5932 if ( ! control.setting ) { 5933 control.setting = new api.Value(); 5934 } 5935 5936 control.previewElements = {}; 5937 5938 control.container.find( '.preview-control-element' ).each( function() { 5939 node = $( this ); 5940 component = node.data( 'component' ); 5941 element = new api.Element( node ); 5942 control.previewElements[ component ] = element; 5943 control.elements.push( element ); 5944 } ); 5945 5946 url = control.previewElements.url; 5947 input = control.previewElements.input; 5948 button = control.previewElements.button; 5949 5950 input.link( control.setting ); 5951 url.link( control.setting ); 5952 5953 url.bind( function( value ) { 5954 url.element.parent().attr( { 5955 href: value, 5956 target: api.settings.changeset.uuid 5957 } ); 5958 } ); 5959 5960 api.bind( 'ready', control.updatePreviewLink ); 5961 api.state( 'saved' ).bind( control.updatePreviewLink ); 5962 api.state( 'changesetStatus' ).bind( control.updatePreviewLink ); 5963 api.state( 'activated' ).bind( control.updatePreviewLink ); 5964 api.previewer.previewUrl.bind( control.updatePreviewLink ); 5965 5966 button.element.on( 'click', function( event ) { 5967 event.preventDefault(); 5968 if ( control.setting() ) { 5969 input.element.select(); 5970 document.execCommand( 'copy' ); 5971 button( button.element.data( 'copied-text' ) ); 5972 } 5973 } ); 5974 5975 url.element.parent().on( 'click', function( event ) { 5976 if ( $( this ).hasClass( 'disabled' ) ) { 5977 event.preventDefault(); 5978 } 5979 } ); 5980 5981 button.element.on( 'mouseenter', function() { 5982 if ( control.setting() ) { 5983 button( button.element.data( 'copy-text' ) ); 5984 } 5985 } ); 5986 }, 5987 5988 /** 5989 * Updates Preview Link 5990 * 5991 * @since 4.9.0 5992 * @return {void} 5993 */ 5994 updatePreviewLink: function updatePreviewLink() { 5995 var control = this, unsavedDirtyValues; 5996 5997 unsavedDirtyValues = ! api.state( 'saved' ).get() || '' === api.state( 'changesetStatus' ).get() || 'auto-draft' === api.state( 'changesetStatus' ).get(); 5998 5999 control.toggleSaveNotification( unsavedDirtyValues ); 6000 control.previewElements.url.element.parent().toggleClass( 'disabled', unsavedDirtyValues ); 6001 control.previewElements.button.element.prop( 'disabled', unsavedDirtyValues ); 6002 control.setting.set( api.previewer.getFrontendPreviewUrl() ); 6003 }, 6004 6005 /** 6006 * Toggles save notification. 6007 * 6008 * @since 4.9.0 6009 * @param {boolean} notify Add or remove notification. 6010 * @return {void} 6011 */ 6012 toggleSaveNotification: function toggleSaveNotification( notify ) { 6013 var control = this, notificationCode, notification; 6014 6015 notificationCode = 'changes_not_saved'; 6016 6017 if ( notify ) { 6018 notification = new api.Notification( notificationCode, { 6019 type: 'info', 6020 message: api.l10n.saveBeforeShare 6021 } ); 6022 control.notifications.add( notification ); 6023 } else { 6024 control.notifications.remove( notificationCode ); 6025 } 6026 } 6027 }); 6028 6029 /** 6030 * Change objects contained within the main customize object to Settings. 6031 * 6032 * @alias wp.customize.defaultConstructor 6033 */ 6034 api.defaultConstructor = api.Setting; 6035 6036 /** 6037 * Callback for resolved controls. 6038 * 6039 * @callback wp.customize.deferredControlsCallback 6040 * @param {wp.customize.Control[]} controls Resolved controls. 6041 */ 6042 6043 /** 6044 * Collection of all registered controls. 6045 * 6046 * @alias wp.customize.control 6047 * 6048 * @since 3.4.0 6049 * 6050 * @type {Function} 6051 * @param {...string} ids - One or more ids for controls to obtain. 6052 * @param {deferredControlsCallback} [callback] - Function called when all supplied controls exist. 6053 * @return {wp.customize.Control|undefined|jQuery.promise} Control instance or undefined (if function called with one id param), 6054 * or promise resolving to requested controls. 6055 * 6056 * @example <caption>Loop over all registered controls.</caption> 6057 * wp.customize.control.each( function( control ) { ... } ); 6058 * 6059 * @example <caption>Getting `background_color` control instance.</caption> 6060 * control = wp.customize.control( 'background_color' ); 6061 * 6062 * @example <caption>Check if control exists.</caption> 6063 * hasControl = wp.customize.control.has( 'background_color' ); 6064 * 6065 * @example <caption>Deferred getting of `background_color` control until it exists, using callback.</caption> 6066 * wp.customize.control( 'background_color', function( control ) { ... } ); 6067 * 6068 * @example <caption>Get title and tagline controls when they both exist, using promise (only available when multiple IDs are present).</caption> 6069 * promise = wp.customize.control( 'blogname', 'blogdescription' ); 6070 * promise.done( function( titleControl, taglineControl ) { ... } ); 6071 * 6072 * @example <caption>Get title and tagline controls when they both exist, using callback.</caption> 6073 * wp.customize.control( 'blogname', 'blogdescription', function( titleControl, taglineControl ) { ... } ); 6074 * 6075 * @example <caption>Getting setting value for `background_color` control.</caption> 6076 * value = wp.customize.control( 'background_color ').setting.get(); 6077 * value = wp.customize( 'background_color' ).get(); // Same as above, since setting ID and control ID are the same. 6078 * 6079 * @example <caption>Add new control for site title.</caption> 6080 * wp.customize.control.add( new wp.customize.Control( 'other_blogname', { 6081 * setting: 'blogname', 6082 * type: 'text', 6083 * label: 'Site title', 6084 * section: 'other_site_identify' 6085 * } ) ); 6086 * 6087 * @example <caption>Remove control.</caption> 6088 * wp.customize.control.remove( 'other_blogname' ); 6089 * 6090 * @example <caption>Listen for control being added.</caption> 6091 * wp.customize.control.bind( 'add', function( addedControl ) { ... } ) 6092 * 6093 * @example <caption>Listen for control being removed.</caption> 6094 * wp.customize.control.bind( 'removed', function( removedControl ) { ... } ) 6095 */ 6096 api.control = new api.Values({ defaultConstructor: api.Control }); 6097 6098 /** 6099 * Callback for resolved sections. 6100 * 6101 * @callback wp.customize.deferredSectionsCallback 6102 * @param {wp.customize.Section[]} sections Resolved sections. 6103 */ 6104 6105 /** 6106 * Collection of all registered sections. 6107 * 6108 * @alias wp.customize.section 6109 * 6110 * @since 3.4.0 6111 * 6112 * @type {Function} 6113 * @param {...string} ids - One or more ids for sections to obtain. 6114 * @param {deferredSectionsCallback} [callback] - Function called when all supplied sections exist. 6115 * @return {wp.customize.Section|undefined|jQuery.promise} Section instance or undefined (if function called with one id param), 6116 * or promise resolving to requested sections. 6117 * 6118 * @example <caption>Loop over all registered sections.</caption> 6119 * wp.customize.section.each( function( section ) { ... } ) 6120 * 6121 * @example <caption>Getting `title_tagline` section instance.</caption> 6122 * section = wp.customize.section( 'title_tagline' ) 6123 * 6124 * @example <caption>Expand dynamically-created section when it exists.</caption> 6125 * wp.customize.section( 'dynamically_created', function( section ) { 6126 * section.expand(); 6127 * } ); 6128 * 6129 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6130 */ 6131 api.section = new api.Values({ defaultConstructor: api.Section }); 6132 6133 /** 6134 * Callback for resolved panels. 6135 * 6136 * @callback wp.customize.deferredPanelsCallback 6137 * @param {wp.customize.Panel[]} panels Resolved panels. 6138 */ 6139 6140 /** 6141 * Collection of all registered panels. 6142 * 6143 * @alias wp.customize.panel 6144 * 6145 * @since 4.0.0 6146 * 6147 * @type {Function} 6148 * @param {...string} ids - One or more ids for panels to obtain. 6149 * @param {deferredPanelsCallback} [callback] - Function called when all supplied panels exist. 6150 * @return {wp.customize.Panel|undefined|jQuery.promise} Panel instance or undefined (if function called with one id param), 6151 * or promise resolving to requested panels. 6152 * 6153 * @example <caption>Loop over all registered panels.</caption> 6154 * wp.customize.panel.each( function( panel ) { ... } ) 6155 * 6156 * @example <caption>Getting nav_menus panel instance.</caption> 6157 * panel = wp.customize.panel( 'nav_menus' ); 6158 * 6159 * @example <caption>Expand dynamically-created panel when it exists.</caption> 6160 * wp.customize.panel( 'dynamically_created', function( panel ) { 6161 * panel.expand(); 6162 * } ); 6163 * 6164 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6165 */ 6166 api.panel = new api.Values({ defaultConstructor: api.Panel }); 6167 6168 /** 6169 * Callback for resolved notifications. 6170 * 6171 * @callback wp.customize.deferredNotificationsCallback 6172 * @param {wp.customize.Notification[]} notifications Resolved notifications. 6173 */ 6174 6175 /** 6176 * Collection of all global notifications. 6177 * 6178 * @alias wp.customize.notifications 6179 * 6180 * @since 4.9.0 6181 * 6182 * @type {Function} 6183 * @param {...string} codes - One or more codes for notifications to obtain. 6184 * @param {deferredNotificationsCallback} [callback] - Function called when all supplied notifications exist. 6185 * @return {wp.customize.Notification|undefined|jQuery.promise} Notification instance or undefined (if function called with one code param), 6186 * or promise resolving to requested notifications. 6187 * 6188 * @example <caption>Check if existing notification</caption> 6189 * exists = wp.customize.notifications.has( 'a_new_day_arrived' ); 6190 * 6191 * @example <caption>Obtain existing notification</caption> 6192 * notification = wp.customize.notifications( 'a_new_day_arrived' ); 6193 * 6194 * @example <caption>Obtain notification that may not exist yet.</caption> 6195 * wp.customize.notifications( 'a_new_day_arrived', function( notification ) { ... } ); 6196 * 6197 * @example <caption>Add a warning notification.</caption> 6198 * wp.customize.notifications.add( new wp.customize.Notification( 'midnight_almost_here', { 6199 * type: 'warning', 6200 * message: 'Midnight has almost arrived!', 6201 * dismissible: true 6202 * } ) ); 6203 * 6204 * @example <caption>Remove a notification.</caption> 6205 * wp.customize.notifications.remove( 'a_new_day_arrived' ); 6206 * 6207 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6208 */ 6209 api.notifications = new api.Notifications(); 6210 6211 api.PreviewFrame = api.Messenger.extend(/** @lends wp.customize.PreviewFrame.prototype */{ 6212 sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity. 6213 6214 /** 6215 * An object that fetches a preview in the background of the document, which 6216 * allows for seamless replacement of an existing preview. 6217 * 6218 * @constructs wp.customize.PreviewFrame 6219 * @augments wp.customize.Messenger 6220 * 6221 * @param {Object} params.container 6222 * @param {Object} params.previewUrl 6223 * @param {Object} params.query 6224 * @param {Object} options 6225 */ 6226 initialize: function( params, options ) { 6227 var deferred = $.Deferred(); 6228 6229 /* 6230 * Make the instance of the PreviewFrame the promise object 6231 * so other objects can easily interact with it. 6232 */ 6233 deferred.promise( this ); 6234 6235 this.container = params.container; 6236 6237 $.extend( params, { channel: api.PreviewFrame.uuid() }); 6238 6239 api.Messenger.prototype.initialize.call( this, params, options ); 6240 6241 this.add( 'previewUrl', params.previewUrl ); 6242 6243 this.query = $.extend( params.query || {}, { customize_messenger_channel: this.channel() }); 6244 6245 this.run( deferred ); 6246 }, 6247 6248 /** 6249 * Run the preview request. 6250 * 6251 * @param {Object} deferred jQuery Deferred object to be resolved with 6252 * the request. 6253 */ 6254 run: function( deferred ) { 6255 var previewFrame = this, 6256 loaded = false, 6257 ready = false, 6258 readyData = null, 6259 hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized, 6260 urlParser, 6261 params, 6262 form; 6263 6264 if ( previewFrame._ready ) { 6265 previewFrame.unbind( 'ready', previewFrame._ready ); 6266 } 6267 6268 previewFrame._ready = function( data ) { 6269 ready = true; 6270 readyData = data; 6271 previewFrame.container.addClass( 'iframe-ready' ); 6272 if ( ! data ) { 6273 return; 6274 } 6275 6276 if ( loaded ) { 6277 deferred.resolveWith( previewFrame, [ data ] ); 6278 } 6279 }; 6280 6281 previewFrame.bind( 'ready', previewFrame._ready ); 6282 6283 urlParser = document.createElement( 'a' ); 6284 urlParser.href = previewFrame.previewUrl(); 6285 6286 params = _.extend( 6287 api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 6288 { 6289 customize_changeset_uuid: previewFrame.query.customize_changeset_uuid, 6290 customize_theme: previewFrame.query.customize_theme, 6291 customize_messenger_channel: previewFrame.query.customize_messenger_channel 6292 } 6293 ); 6294 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { 6295 params.customize_autosaved = 'on'; 6296 } 6297 6298 urlParser.search = $.param( params ); 6299 previewFrame.iframe = $( '<iframe />', { 6300 title: api.l10n.previewIframeTitle, 6301 name: 'customize-' + previewFrame.channel() 6302 } ); 6303 previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149. 6304 previewFrame.iframe.attr( 'sandbox', 'allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-presentation allow-same-origin allow-scripts' ); 6305 6306 if ( ! hasPendingChangesetUpdate ) { 6307 previewFrame.iframe.attr( 'src', urlParser.href ); 6308 } else { 6309 previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes. 6310 } 6311 6312 previewFrame.iframe.appendTo( previewFrame.container ); 6313 previewFrame.targetWindow( previewFrame.iframe[0].contentWindow ); 6314 6315 /* 6316 * Submit customized data in POST request to preview frame window since 6317 * there are setting value changes not yet written to changeset. 6318 */ 6319 if ( hasPendingChangesetUpdate ) { 6320 form = $( '<form>', { 6321 action: urlParser.href, 6322 target: previewFrame.iframe.attr( 'name' ), 6323 method: 'post', 6324 hidden: 'hidden' 6325 } ); 6326 form.append( $( '<input>', { 6327 type: 'hidden', 6328 name: '_method', 6329 value: 'GET' 6330 } ) ); 6331 _.each( previewFrame.query, function( value, key ) { 6332 form.append( $( '<input>', { 6333 type: 'hidden', 6334 name: key, 6335 value: value 6336 } ) ); 6337 } ); 6338 previewFrame.container.append( form ); 6339 form.trigger( 'submit' ); 6340 form.remove(); // No need to keep the form around after submitted. 6341 } 6342 6343 previewFrame.bind( 'iframe-loading-error', function( error ) { 6344 previewFrame.iframe.remove(); 6345 6346 // Check if the user is not logged in. 6347 if ( 0 === error ) { 6348 previewFrame.login( deferred ); 6349 return; 6350 } 6351 6352 // Check for cheaters. 6353 if ( -1 === error ) { 6354 deferred.rejectWith( previewFrame, [ 'cheatin' ] ); 6355 return; 6356 } 6357 6358 deferred.rejectWith( previewFrame, [ 'request failure' ] ); 6359 } ); 6360 6361 previewFrame.iframe.one( 'load', function() { 6362 loaded = true; 6363 6364 if ( ready ) { 6365 deferred.resolveWith( previewFrame, [ readyData ] ); 6366 } else { 6367 setTimeout( function() { 6368 deferred.rejectWith( previewFrame, [ 'ready timeout' ] ); 6369 }, previewFrame.sensitivity ); 6370 } 6371 }); 6372 }, 6373 6374 login: function( deferred ) { 6375 var self = this, 6376 reject; 6377 6378 reject = function() { 6379 deferred.rejectWith( self, [ 'logged out' ] ); 6380 }; 6381 6382 if ( this.triedLogin ) { 6383 return reject(); 6384 } 6385 6386 // Check if we have an admin cookie. 6387 $.get( api.settings.url.ajax, { 6388 action: 'logged-in' 6389 }).fail( reject ).done( function( response ) { 6390 var iframe; 6391 6392 if ( '1' !== response ) { 6393 reject(); 6394 } 6395 6396 iframe = $( '<iframe />', { 'src': self.previewUrl(), 'title': api.l10n.previewIframeTitle } ).hide(); 6397 iframe.appendTo( self.container ); 6398 iframe.on( 'load', function() { 6399 self.triedLogin = true; 6400 6401 iframe.remove(); 6402 self.run( deferred ); 6403 }); 6404 }); 6405 }, 6406 6407 destroy: function() { 6408 api.Messenger.prototype.destroy.call( this ); 6409 6410 if ( this.iframe ) { 6411 this.iframe.remove(); 6412 } 6413 6414 delete this.iframe; 6415 delete this.targetWindow; 6416 } 6417 }); 6418 6419 (function(){ 6420 var id = 0; 6421 /** 6422 * Return an incremented ID for a preview messenger channel. 6423 * 6424 * This function is named "uuid" for historical reasons, but it is a 6425 * misnomer as it is not an actual UUID, and it is not universally unique. 6426 * This is not to be confused with `api.settings.changeset.uuid`. 6427 * 6428 * @return {string} 6429 */ 6430 api.PreviewFrame.uuid = function() { 6431 return 'preview-' + String( id++ ); 6432 }; 6433 }()); 6434 6435 /** 6436 * Set the document title of the customizer. 6437 * 6438 * @alias wp.customize.setDocumentTitle 6439 * 6440 * @since 4.1.0 6441 * 6442 * @param {string} documentTitle 6443 */ 6444 api.setDocumentTitle = function ( documentTitle ) { 6445 var tmpl, title; 6446 tmpl = api.settings.documentTitleTmpl; 6447 title = tmpl.replace( '%s', documentTitle ); 6448 document.title = title; 6449 api.trigger( 'title', title ); 6450 }; 6451 6452 api.Previewer = api.Messenger.extend(/** @lends wp.customize.Previewer.prototype */{ 6453 refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh. 6454 6455 /** 6456 * @constructs wp.customize.Previewer 6457 * @augments wp.customize.Messenger 6458 * 6459 * @param {Array} params.allowedUrls 6460 * @param {string} params.container A selector or jQuery element for the preview 6461 * frame to be placed. 6462 * @param {string} params.form 6463 * @param {string} params.previewUrl The URL to preview. 6464 * @param {Object} options 6465 */ 6466 initialize: function( params, options ) { 6467 var previewer = this, 6468 urlParser = document.createElement( 'a' ); 6469 6470 $.extend( previewer, options || {} ); 6471 previewer.deferred = { 6472 active: $.Deferred() 6473 }; 6474 6475 // Debounce to prevent hammering server and then wait for any pending update requests. 6476 previewer.refresh = _.debounce( 6477 ( function( originalRefresh ) { 6478 return function() { 6479 var isProcessingComplete, refreshOnceProcessingComplete; 6480 isProcessingComplete = function() { 6481 return 0 === api.state( 'processing' ).get(); 6482 }; 6483 if ( isProcessingComplete() ) { 6484 originalRefresh.call( previewer ); 6485 } else { 6486 refreshOnceProcessingComplete = function() { 6487 if ( isProcessingComplete() ) { 6488 originalRefresh.call( previewer ); 6489 api.state( 'processing' ).unbind( refreshOnceProcessingComplete ); 6490 } 6491 }; 6492 api.state( 'processing' ).bind( refreshOnceProcessingComplete ); 6493 } 6494 }; 6495 }( previewer.refresh ) ), 6496 previewer.refreshBuffer 6497 ); 6498 6499 previewer.container = api.ensure( params.container ); 6500 previewer.allowedUrls = params.allowedUrls; 6501 6502 params.url = window.location.href; 6503 6504 api.Messenger.prototype.initialize.call( previewer, params ); 6505 6506 urlParser.href = previewer.origin(); 6507 previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) ); 6508 6509 /* 6510 * Limit the URL to internal, front-end links. 6511 * 6512 * If the front end and the admin are served from the same domain, load the 6513 * preview over ssl if the Customizer is being loaded over ssl. This avoids 6514 * insecure content warnings. This is not attempted if the admin and front end 6515 * are on different domains to avoid the case where the front end doesn't have 6516 * ssl certs. 6517 */ 6518 6519 previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) { 6520 var result = null, urlParser, queryParams, parsedAllowedUrl, parsedCandidateUrls = []; 6521 urlParser = document.createElement( 'a' ); 6522 urlParser.href = to; 6523 6524 // Abort if URL is for admin or (static) files in wp-includes or wp-content. 6525 if ( /\/wp-(admin|includes|content)(\/|$)/.test( urlParser.pathname ) ) { 6526 return null; 6527 } 6528 6529 // Remove state query params. 6530 if ( urlParser.search.length > 1 ) { 6531 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 6532 delete queryParams.customize_changeset_uuid; 6533 delete queryParams.customize_theme; 6534 delete queryParams.customize_messenger_channel; 6535 delete queryParams.customize_autosaved; 6536 if ( _.isEmpty( queryParams ) ) { 6537 urlParser.search = ''; 6538 } else { 6539 urlParser.search = $.param( queryParams ); 6540 } 6541 } 6542 6543 parsedCandidateUrls.push( urlParser ); 6544 6545 // Prepend list with URL that matches the scheme/protocol of the iframe. 6546 if ( previewer.scheme.get() + ':' !== urlParser.protocol ) { 6547 urlParser = document.createElement( 'a' ); 6548 urlParser.href = parsedCandidateUrls[0].href; 6549 urlParser.protocol = previewer.scheme.get() + ':'; 6550 parsedCandidateUrls.unshift( urlParser ); 6551 } 6552 6553 // Attempt to match the URL to the control frame's scheme and check if it's allowed. If not, try the original URL. 6554 parsedAllowedUrl = document.createElement( 'a' ); 6555 _.find( parsedCandidateUrls, function( parsedCandidateUrl ) { 6556 return ! _.isUndefined( _.find( previewer.allowedUrls, function( allowedUrl ) { 6557 parsedAllowedUrl.href = allowedUrl; 6558 if ( urlParser.protocol === parsedAllowedUrl.protocol && urlParser.host === parsedAllowedUrl.host && 0 === urlParser.pathname.indexOf( parsedAllowedUrl.pathname.replace( /\/$/, '' ) ) ) { 6559 result = parsedCandidateUrl.href; 6560 return true; 6561 } 6562 } ) ); 6563 } ); 6564 6565 return result; 6566 }); 6567 6568 previewer.bind( 'ready', previewer.ready ); 6569 6570 // Start listening for keep-alive messages when iframe first loads. 6571 previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) ); 6572 6573 previewer.bind( 'synced', function() { 6574 previewer.send( 'active' ); 6575 } ); 6576 6577 // Refresh the preview when the URL is changed (but not yet). 6578 previewer.previewUrl.bind( previewer.refresh ); 6579 6580 previewer.scroll = 0; 6581 previewer.bind( 'scroll', function( distance ) { 6582 previewer.scroll = distance; 6583 }); 6584 6585 // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh. 6586 previewer.bind( 'url', function( url ) { 6587 var onUrlChange, urlChanged = false; 6588 previewer.scroll = 0; 6589 onUrlChange = function() { 6590 urlChanged = true; 6591 }; 6592 previewer.previewUrl.bind( onUrlChange ); 6593 previewer.previewUrl.set( url ); 6594 previewer.previewUrl.unbind( onUrlChange ); 6595 if ( ! urlChanged ) { 6596 previewer.refresh(); 6597 } 6598 } ); 6599 6600 // Update the document title when the preview changes. 6601 previewer.bind( 'documentTitle', function ( title ) { 6602 api.setDocumentTitle( title ); 6603 } ); 6604 }, 6605 6606 /** 6607 * Handle the preview receiving the ready message. 6608 * 6609 * @since 4.7.0 6610 * @access public 6611 * 6612 * @param {Object} data - Data from preview. 6613 * @param {string} data.currentUrl - Current URL. 6614 * @param {Object} data.activePanels - Active panels. 6615 * @param {Object} data.activeSections Active sections. 6616 * @param {Object} data.activeControls Active controls. 6617 * @return {void} 6618 */ 6619 ready: function( data ) { 6620 var previewer = this, synced = {}, constructs; 6621 6622 synced.settings = api.get(); 6623 synced['settings-modified-while-loading'] = previewer.settingsModifiedWhileLoading; 6624 if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) { 6625 synced.scroll = previewer.scroll; 6626 } 6627 synced['edit-shortcut-visibility'] = api.state( 'editShortcutVisibility' ).get(); 6628 previewer.send( 'sync', synced ); 6629 6630 // Set the previewUrl without causing the url to set the iframe. 6631 if ( data.currentUrl ) { 6632 previewer.previewUrl.unbind( previewer.refresh ); 6633 previewer.previewUrl.set( data.currentUrl ); 6634 previewer.previewUrl.bind( previewer.refresh ); 6635 } 6636 6637 /* 6638 * Walk over all panels, sections, and controls and set their 6639 * respective active states to true if the preview explicitly 6640 * indicates as such. 6641 */ 6642 constructs = { 6643 panel: data.activePanels, 6644 section: data.activeSections, 6645 control: data.activeControls 6646 }; 6647 _( constructs ).each( function ( activeConstructs, type ) { 6648 api[ type ].each( function ( construct, id ) { 6649 var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] ); 6650 6651 /* 6652 * If the construct was created statically in PHP (not dynamically in JS) 6653 * then consider a missing (undefined) value in the activeConstructs to 6654 * mean it should be deactivated (since it is gone). But if it is 6655 * dynamically created then only toggle activation if the value is defined, 6656 * as this means that the construct was also then correspondingly 6657 * created statically in PHP and the active callback is available. 6658 * Otherwise, dynamically-created constructs should normally have 6659 * their active states toggled in JS rather than from PHP. 6660 */ 6661 if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) { 6662 if ( activeConstructs[ id ] ) { 6663 construct.activate(); 6664 } else { 6665 construct.deactivate(); 6666 } 6667 } 6668 } ); 6669 } ); 6670 6671 if ( data.settingValidities ) { 6672 api._handleSettingValidities( { 6673 settingValidities: data.settingValidities, 6674 focusInvalidControl: false 6675 } ); 6676 } 6677 }, 6678 6679 /** 6680 * Keep the preview alive by listening for ready and keep-alive messages. 6681 * 6682 * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL. 6683 * 6684 * @since 4.7.0 6685 * @access public 6686 * 6687 * @return {void} 6688 */ 6689 keepPreviewAlive: function keepPreviewAlive() { 6690 var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck; 6691 6692 /** 6693 * Schedule a preview keep-alive check. 6694 * 6695 * Note that if a page load takes longer than keepAliveCheck milliseconds, 6696 * the keep-alive messages will still be getting sent from the previous 6697 * URL. 6698 */ 6699 scheduleKeepAliveCheck = function() { 6700 timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck ); 6701 }; 6702 6703 /** 6704 * Set the previewerAlive state to true when receiving a message from the preview. 6705 */ 6706 keepAliveTick = function() { 6707 api.state( 'previewerAlive' ).set( true ); 6708 clearTimeout( timeoutId ); 6709 scheduleKeepAliveCheck(); 6710 }; 6711 6712 /** 6713 * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message. 6714 * 6715 * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser 6716 * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage 6717 * transport to use refresh instead, causing the preview frame also to be replaced with the current 6718 * allowed preview URL. 6719 */ 6720 handleMissingKeepAlive = function() { 6721 api.state( 'previewerAlive' ).set( false ); 6722 }; 6723 scheduleKeepAliveCheck(); 6724 6725 previewer.bind( 'ready', keepAliveTick ); 6726 previewer.bind( 'keep-alive', keepAliveTick ); 6727 }, 6728 6729 /** 6730 * Query string data sent with each preview request. 6731 * 6732 * @abstract 6733 */ 6734 query: function() {}, 6735 6736 abort: function() { 6737 if ( this.loading ) { 6738 this.loading.destroy(); 6739 delete this.loading; 6740 } 6741 }, 6742 6743 /** 6744 * Refresh the preview seamlessly. 6745 * 6746 * @since 3.4.0 6747 * @access public 6748 * 6749 * @return {void} 6750 */ 6751 refresh: function() { 6752 var previewer = this, onSettingChange; 6753 6754 // Display loading indicator. 6755 previewer.send( 'loading-initiated' ); 6756 6757 previewer.abort(); 6758 6759 previewer.loading = new api.PreviewFrame({ 6760 url: previewer.url(), 6761 previewUrl: previewer.previewUrl(), 6762 query: previewer.query( { excludeCustomizedSaved: true } ) || {}, 6763 container: previewer.container 6764 }); 6765 6766 previewer.settingsModifiedWhileLoading = {}; 6767 onSettingChange = function( setting ) { 6768 previewer.settingsModifiedWhileLoading[ setting.id ] = true; 6769 }; 6770 api.bind( 'change', onSettingChange ); 6771 previewer.loading.always( function() { 6772 api.unbind( 'change', onSettingChange ); 6773 } ); 6774 6775 previewer.loading.done( function( readyData ) { 6776 var loadingFrame = this, onceSynced; 6777 6778 previewer.preview = loadingFrame; 6779 previewer.targetWindow( loadingFrame.targetWindow() ); 6780 previewer.channel( loadingFrame.channel() ); 6781 6782 onceSynced = function() { 6783 loadingFrame.unbind( 'synced', onceSynced ); 6784 if ( previewer._previousPreview ) { 6785 previewer._previousPreview.destroy(); 6786 } 6787 previewer._previousPreview = previewer.preview; 6788 previewer.deferred.active.resolve(); 6789 delete previewer.loading; 6790 }; 6791 loadingFrame.bind( 'synced', onceSynced ); 6792 6793 // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh. 6794 previewer.trigger( 'ready', readyData ); 6795 }); 6796 6797 previewer.loading.fail( function( reason ) { 6798 previewer.send( 'loading-failed' ); 6799 6800 if ( 'logged out' === reason ) { 6801 if ( previewer.preview ) { 6802 previewer.preview.destroy(); 6803 delete previewer.preview; 6804 } 6805 6806 previewer.login().done( previewer.refresh ); 6807 } 6808 6809 if ( 'cheatin' === reason ) { 6810 previewer.cheatin(); 6811 } 6812 }); 6813 }, 6814 6815 login: function() { 6816 var previewer = this, 6817 deferred, messenger, iframe; 6818 6819 if ( this._login ) { 6820 return this._login; 6821 } 6822 6823 deferred = $.Deferred(); 6824 this._login = deferred.promise(); 6825 6826 messenger = new api.Messenger({ 6827 channel: 'login', 6828 url: api.settings.url.login 6829 }); 6830 6831 iframe = $( '<iframe />', { 'src': api.settings.url.login, 'title': api.l10n.loginIframeTitle } ).appendTo( this.container ); 6832 6833 messenger.targetWindow( iframe[0].contentWindow ); 6834 6835 messenger.bind( 'login', function () { 6836 var refreshNonces = previewer.refreshNonces(); 6837 6838 refreshNonces.always( function() { 6839 iframe.remove(); 6840 messenger.destroy(); 6841 delete previewer._login; 6842 }); 6843 6844 refreshNonces.done( function() { 6845 deferred.resolve(); 6846 }); 6847 6848 refreshNonces.fail( function() { 6849 previewer.cheatin(); 6850 deferred.reject(); 6851 }); 6852 }); 6853 6854 return this._login; 6855 }, 6856 6857 cheatin: function() { 6858 $( document.body ).empty().addClass( 'cheatin' ).append( 6859 '<h1>' + api.l10n.notAllowedHeading + '</h1>' + 6860 '<p>' + api.l10n.notAllowed + '</p>' 6861 ); 6862 }, 6863 6864 refreshNonces: function() { 6865 var request, deferred = $.Deferred(); 6866 6867 deferred.promise(); 6868 6869 request = wp.ajax.post( 'customize_refresh_nonces', { 6870 wp_customize: 'on', 6871 customize_theme: api.settings.theme.stylesheet 6872 }); 6873 6874 request.done( function( response ) { 6875 api.trigger( 'nonce-refresh', response ); 6876 deferred.resolve(); 6877 }); 6878 6879 request.fail( function() { 6880 deferred.reject(); 6881 }); 6882 6883 return deferred; 6884 } 6885 }); 6886 6887 api.settingConstructor = {}; 6888 api.controlConstructor = { 6889 color: api.ColorControl, 6890 media: api.MediaControl, 6891 upload: api.UploadControl, 6892 image: api.ImageControl, 6893 cropped_image: api.CroppedImageControl, 6894 site_icon: api.SiteIconControl, 6895 header: api.HeaderControl, 6896 background: api.BackgroundControl, 6897 background_position: api.BackgroundPositionControl, 6898 theme: api.ThemeControl, 6899 date_time: api.DateTimeControl, 6900 code_editor: api.CodeEditorControl 6901 }; 6902 api.panelConstructor = { 6903 themes: api.ThemesPanel 6904 }; 6905 api.sectionConstructor = { 6906 themes: api.ThemesSection, 6907 outer: api.OuterSection 6908 }; 6909 6910 /** 6911 * Handle setting_validities in an error response for the customize-save request. 6912 * 6913 * Add notifications to the settings and focus on the first control that has an invalid setting. 6914 * 6915 * @alias wp.customize._handleSettingValidities 6916 * 6917 * @since 4.6.0 6918 * @private 6919 * 6920 * @param {Object} args 6921 * @param {Object} args.settingValidities 6922 * @param {boolean} [args.focusInvalidControl=false] 6923 * @return {void} 6924 */ 6925 api._handleSettingValidities = function handleSettingValidities( args ) { 6926 var invalidSettingControls, invalidSettings = [], wasFocused = false; 6927 6928 // Find the controls that correspond to each invalid setting. 6929 _.each( args.settingValidities, function( validity, settingId ) { 6930 var setting = api( settingId ); 6931 if ( setting ) { 6932 6933 // Add notifications for invalidities. 6934 if ( _.isObject( validity ) ) { 6935 _.each( validity, function( params, code ) { 6936 var notification, existingNotification, needsReplacement = false; 6937 notification = new api.Notification( code, _.extend( { fromServer: true }, params ) ); 6938 6939 // Remove existing notification if already exists for code but differs in parameters. 6940 existingNotification = setting.notifications( notification.code ); 6941 if ( existingNotification ) { 6942 needsReplacement = notification.type !== existingNotification.type || notification.message !== existingNotification.message || ! _.isEqual( notification.data, existingNotification.data ); 6943 } 6944 if ( needsReplacement ) { 6945 setting.notifications.remove( code ); 6946 } 6947 6948 if ( ! setting.notifications.has( notification.code ) ) { 6949 setting.notifications.add( notification ); 6950 } 6951 invalidSettings.push( setting.id ); 6952 } ); 6953 } 6954 6955 // Remove notification errors that are no longer valid. 6956 setting.notifications.each( function( notification ) { 6957 if ( notification.fromServer && 'error' === notification.type && ( true === validity || ! validity[ notification.code ] ) ) { 6958 setting.notifications.remove( notification.code ); 6959 } 6960 } ); 6961 } 6962 } ); 6963 6964 if ( args.focusInvalidControl ) { 6965 invalidSettingControls = api.findControlsForSettings( invalidSettings ); 6966 6967 // Focus on the first control that is inside of an expanded section (one that is visible). 6968 _( _.values( invalidSettingControls ) ).find( function( controls ) { 6969 return _( controls ).find( function( control ) { 6970 var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded(); 6971 if ( isExpanded && control.expanded ) { 6972 isExpanded = control.expanded(); 6973 } 6974 if ( isExpanded ) { 6975 control.focus(); 6976 wasFocused = true; 6977 } 6978 return wasFocused; 6979 } ); 6980 } ); 6981 6982 // Focus on the first invalid control. 6983 if ( ! wasFocused && ! _.isEmpty( invalidSettingControls ) ) { 6984 _.values( invalidSettingControls )[0][0].focus(); 6985 } 6986 } 6987 }; 6988 6989 /** 6990 * Find all controls associated with the given settings. 6991 * 6992 * @alias wp.customize.findControlsForSettings 6993 * 6994 * @since 4.6.0 6995 * @param {string[]} settingIds Setting IDs. 6996 * @return {Object<string, wp.customize.Control>} Mapping setting ids to arrays of controls. 6997 */ 6998 api.findControlsForSettings = function findControlsForSettings( settingIds ) { 6999 var controls = {}, settingControls; 7000 _.each( _.unique( settingIds ), function( settingId ) { 7001 var setting = api( settingId ); 7002 if ( setting ) { 7003 settingControls = setting.findControls(); 7004 if ( settingControls && settingControls.length > 0 ) { 7005 controls[ settingId ] = settingControls; 7006 } 7007 } 7008 } ); 7009 return controls; 7010 }; 7011 7012 /** 7013 * Sort panels, sections, controls by priorities. Hide empty sections and panels. 7014 * 7015 * @alias wp.customize.reflowPaneContents 7016 * 7017 * @since 4.1.0 7018 */ 7019 api.reflowPaneContents = _.bind( function () { 7020 7021 var appendContainer, activeElement, rootHeadContainers, rootNodes = [], wasReflowed = false; 7022 7023 if ( document.activeElement ) { 7024 activeElement = $( document.activeElement ); 7025 } 7026 7027 // Sort the sections within each panel. 7028 api.panel.each( function ( panel ) { 7029 if ( 'themes' === panel.id ) { 7030 return; // Don't reflow theme sections, as doing so moves them after the themes container. 7031 } 7032 7033 var sections = panel.sections(), 7034 sectionHeadContainers = _.pluck( sections, 'headContainer' ); 7035 rootNodes.push( panel ); 7036 appendContainer = ( panel.contentContainer.is( 'ul' ) ) ? panel.contentContainer : panel.contentContainer.find( 'ul:first' ); 7037 if ( ! api.utils.areElementListsEqual( sectionHeadContainers, appendContainer.children( '[id]' ) ) ) { 7038 _( sections ).each( function ( section ) { 7039 appendContainer.append( section.headContainer ); 7040 } ); 7041 wasReflowed = true; 7042 } 7043 } ); 7044 7045 // Sort the controls within each section. 7046 api.section.each( function ( section ) { 7047 var controls = section.controls(), 7048 controlContainers = _.pluck( controls, 'container' ); 7049 if ( ! section.panel() ) { 7050 rootNodes.push( section ); 7051 } 7052 appendContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' ); 7053 if ( ! api.utils.areElementListsEqual( controlContainers, appendContainer.children( '[id]' ) ) ) { 7054 _( controls ).each( function ( control ) { 7055 appendContainer.append( control.container ); 7056 } ); 7057 wasReflowed = true; 7058 } 7059 } ); 7060 7061 // Sort the root panels and sections. 7062 rootNodes.sort( api.utils.prioritySort ); 7063 rootHeadContainers = _.pluck( rootNodes, 'headContainer' ); 7064 appendContainer = $( '#customize-theme-controls .customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable. 7065 if ( ! api.utils.areElementListsEqual( rootHeadContainers, appendContainer.children() ) ) { 7066 _( rootNodes ).each( function ( rootNode ) { 7067 appendContainer.append( rootNode.headContainer ); 7068 } ); 7069 wasReflowed = true; 7070 } 7071 7072 // Now re-trigger the active Value callbacks so that the panels and sections can decide whether they can be rendered. 7073 api.panel.each( function ( panel ) { 7074 var value = panel.active(); 7075 panel.active.callbacks.fireWith( panel.active, [ value, value ] ); 7076 } ); 7077 api.section.each( function ( section ) { 7078 var value = section.active(); 7079 section.active.callbacks.fireWith( section.active, [ value, value ] ); 7080 } ); 7081 7082 // Restore focus if there was a reflow and there was an active (focused) element. 7083 if ( wasReflowed && activeElement ) { 7084 activeElement.trigger( 'focus' ); 7085 } 7086 api.trigger( 'pane-contents-reflowed' ); 7087 }, api ); 7088 7089 // Define state values. 7090 api.state = new api.Values(); 7091 _.each( [ 7092 'saved', 7093 'saving', 7094 'trashing', 7095 'activated', 7096 'processing', 7097 'paneVisible', 7098 'expandedPanel', 7099 'expandedSection', 7100 'changesetDate', 7101 'selectedChangesetDate', 7102 'changesetStatus', 7103 'selectedChangesetStatus', 7104 'remainingTimeToPublish', 7105 'previewerAlive', 7106 'editShortcutVisibility', 7107 'changesetLocked', 7108 'previewedDevice' 7109 ], function( name ) { 7110 api.state.create( name ); 7111 }); 7112 7113 $( function() { 7114 api.settings = window._wpCustomizeSettings; 7115 api.l10n = window._wpCustomizeControlsL10n; 7116 7117 // Check if we can run the Customizer. 7118 if ( ! api.settings ) { 7119 return; 7120 } 7121 7122 // Bail if any incompatibilities are found. 7123 if ( ! $.support.postMessage || ( ! $.support.cors && api.settings.isCrossDomain ) ) { 7124 return; 7125 } 7126 7127 if ( null === api.PreviewFrame.prototype.sensitivity ) { 7128 api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity; 7129 } 7130 if ( null === api.Previewer.prototype.refreshBuffer ) { 7131 api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh; 7132 } 7133 7134 var parent, 7135 body = $( document.body ), 7136 overlay = body.children( '.wp-full-overlay' ), 7137 title = $( '#customize-info .panel-title.site-title' ), 7138 closeBtn = $( '.customize-controls-close' ), 7139 saveBtn = $( '#save' ), 7140 btnWrapper = $( '#customize-save-button-wrapper' ), 7141 publishSettingsBtn = $( '#publish-settings' ), 7142 footerActions = $( '#customize-footer-actions' ); 7143 7144 // Add publish settings section in JS instead of PHP since the Customizer depends on it to function. 7145 api.bind( 'ready', function() { 7146 api.section.add( new api.OuterSection( 'publish_settings', { 7147 title: api.l10n.publishSettings, 7148 priority: 0, 7149 active: api.settings.theme.active 7150 } ) ); 7151 } ); 7152 7153 // Set up publish settings section and its controls. 7154 api.section( 'publish_settings', function( section ) { 7155 var updateButtonsState, trashControl, updateSectionActive, isSectionActive, statusControl, dateControl, toggleDateControl, publishWhenTime, pollInterval, updateTimeArrivedPoller, cancelScheduleButtonReminder, timeArrivedPollingInterval = 1000; 7156 7157 trashControl = new api.Control( 'trash_changeset', { 7158 type: 'button', 7159 section: section.id, 7160 priority: 30, 7161 input_attrs: { 7162 'class': 'button-link button-link-delete', 7163 value: api.l10n.discardChanges 7164 } 7165 } ); 7166 api.control.add( trashControl ); 7167 trashControl.deferred.embedded.done( function() { 7168 trashControl.container.find( '.button-link' ).on( 'click', function() { 7169 if ( confirm( api.l10n.trashConfirm ) ) { 7170 wp.customize.previewer.trash(); 7171 } 7172 } ); 7173 } ); 7174 7175 api.control.add( new api.PreviewLinkControl( 'changeset_preview_link', { 7176 section: section.id, 7177 priority: 100 7178 } ) ); 7179 7180 /** 7181 * Return whether the pubish settings section should be active. 7182 * 7183 * @return {boolean} Is section active. 7184 */ 7185 isSectionActive = function() { 7186 if ( ! api.state( 'activated' ).get() ) { 7187 return false; 7188 } 7189 if ( api.state( 'trashing' ).get() || 'trash' === api.state( 'changesetStatus' ).get() ) { 7190 return false; 7191 } 7192 if ( '' === api.state( 'changesetStatus' ).get() && api.state( 'saved' ).get() ) { 7193 return false; 7194 } 7195 return true; 7196 }; 7197 7198 // Make sure publish settings are not available while the theme is not active and the customizer is in a published state. 7199 section.active.validate = isSectionActive; 7200 updateSectionActive = function() { 7201 section.active.set( isSectionActive() ); 7202 }; 7203 api.state( 'activated' ).bind( updateSectionActive ); 7204 api.state( 'trashing' ).bind( updateSectionActive ); 7205 api.state( 'saved' ).bind( updateSectionActive ); 7206 api.state( 'changesetStatus' ).bind( updateSectionActive ); 7207 updateSectionActive(); 7208 7209 // Bind visibility of the publish settings button to whether the section is active. 7210 updateButtonsState = function() { 7211 publishSettingsBtn.toggle( section.active.get() ); 7212 saveBtn.toggleClass( 'has-next-sibling', section.active.get() ); 7213 }; 7214 updateButtonsState(); 7215 section.active.bind( updateButtonsState ); 7216 7217 function highlightScheduleButton() { 7218 if ( ! cancelScheduleButtonReminder ) { 7219 cancelScheduleButtonReminder = api.utils.highlightButton( btnWrapper, { 7220 delay: 1000, 7221 7222 /* 7223 * Only abort the reminder when the save button is focused. 7224 * If the user clicks the settings button to toggle the 7225 * settings closed, we'll still remind them. 7226 */ 7227 focusTarget: saveBtn 7228 } ); 7229 } 7230 } 7231 function cancelHighlightScheduleButton() { 7232 if ( cancelScheduleButtonReminder ) { 7233 cancelScheduleButtonReminder(); 7234 cancelScheduleButtonReminder = null; 7235 } 7236 } 7237 api.state( 'selectedChangesetStatus' ).bind( cancelHighlightScheduleButton ); 7238 7239 section.contentContainer.find( '.customize-action' ).text( api.l10n.updating ); 7240 section.contentContainer.find( '.customize-section-back' ).removeAttr( 'tabindex' ); 7241 publishSettingsBtn.prop( 'disabled', false ); 7242 7243 publishSettingsBtn.on( 'click', function( event ) { 7244 event.preventDefault(); 7245 section.expanded.set( ! section.expanded.get() ); 7246 } ); 7247 7248 section.expanded.bind( function( isExpanded ) { 7249 var defaultChangesetStatus; 7250 publishSettingsBtn.attr( 'aria-expanded', String( isExpanded ) ); 7251 publishSettingsBtn.toggleClass( 'active', isExpanded ); 7252 7253 if ( isExpanded ) { 7254 cancelHighlightScheduleButton(); 7255 return; 7256 } 7257 7258 defaultChangesetStatus = api.state( 'changesetStatus' ).get(); 7259 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) { 7260 defaultChangesetStatus = 'publish'; 7261 } 7262 7263 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) { 7264 highlightScheduleButton(); 7265 } else if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) { 7266 highlightScheduleButton(); 7267 } 7268 } ); 7269 7270 statusControl = new api.Control( 'changeset_status', { 7271 priority: 10, 7272 type: 'radio', 7273 section: 'publish_settings', 7274 setting: api.state( 'selectedChangesetStatus' ), 7275 templateId: 'customize-selected-changeset-status-control', 7276 label: api.l10n.action, 7277 choices: api.settings.changeset.statusChoices 7278 } ); 7279 api.control.add( statusControl ); 7280 7281 dateControl = new api.DateTimeControl( 'changeset_scheduled_date', { 7282 priority: 20, 7283 section: 'publish_settings', 7284 setting: api.state( 'selectedChangesetDate' ), 7285 minYear: ( new Date() ).getFullYear(), 7286 allowPastDate: false, 7287 includeTime: true, 7288 twelveHourFormat: /a/i.test( api.settings.timeFormat ), 7289 description: api.l10n.scheduleDescription 7290 } ); 7291 dateControl.notifications.alt = true; 7292 api.control.add( dateControl ); 7293 7294 publishWhenTime = function() { 7295 api.state( 'selectedChangesetStatus' ).set( 'publish' ); 7296 api.previewer.save(); 7297 }; 7298 7299 // Start countdown for when the dateTime arrives, or clear interval when it is . 7300 updateTimeArrivedPoller = function() { 7301 var shouldPoll = ( 7302 'future' === api.state( 'changesetStatus' ).get() && 7303 'future' === api.state( 'selectedChangesetStatus' ).get() && 7304 api.state( 'changesetDate' ).get() && 7305 api.state( 'selectedChangesetDate' ).get() === api.state( 'changesetDate' ).get() && 7306 api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ) >= 0 7307 ); 7308 7309 if ( shouldPoll && ! pollInterval ) { 7310 pollInterval = setInterval( function() { 7311 var remainingTime = api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ); 7312 api.state( 'remainingTimeToPublish' ).set( remainingTime ); 7313 if ( remainingTime <= 0 ) { 7314 clearInterval( pollInterval ); 7315 pollInterval = 0; 7316 publishWhenTime(); 7317 } 7318 }, timeArrivedPollingInterval ); 7319 } else if ( ! shouldPoll && pollInterval ) { 7320 clearInterval( pollInterval ); 7321 pollInterval = 0; 7322 } 7323 }; 7324 7325 api.state( 'changesetDate' ).bind( updateTimeArrivedPoller ); 7326 api.state( 'selectedChangesetDate' ).bind( updateTimeArrivedPoller ); 7327 api.state( 'changesetStatus' ).bind( updateTimeArrivedPoller ); 7328 api.state( 'selectedChangesetStatus' ).bind( updateTimeArrivedPoller ); 7329 updateTimeArrivedPoller(); 7330 7331 // Ensure dateControl only appears when selected status is future. 7332 dateControl.active.validate = function() { 7333 return 'future' === api.state( 'selectedChangesetStatus' ).get(); 7334 }; 7335 toggleDateControl = function( value ) { 7336 dateControl.active.set( 'future' === value ); 7337 }; 7338 toggleDateControl( api.state( 'selectedChangesetStatus' ).get() ); 7339 api.state( 'selectedChangesetStatus' ).bind( toggleDateControl ); 7340 7341 // Show notification on date control when status is future but it isn't a future date. 7342 api.state( 'saving' ).bind( function( isSaving ) { 7343 if ( isSaving && 'future' === api.state( 'selectedChangesetStatus' ).get() ) { 7344 dateControl.toggleFutureDateNotification( ! dateControl.isFutureDate() ); 7345 } 7346 } ); 7347 } ); 7348 7349 // Prevent the form from saving when enter is pressed on an input or select element. 7350 $('#customize-controls').on( 'keydown', function( e ) { 7351 var isEnter = ( 13 === e.which ), 7352 $el = $( e.target ); 7353 7354 if ( isEnter && ( $el.is( 'input:not([type=button])' ) || $el.is( 'select' ) ) ) { 7355 e.preventDefault(); 7356 } 7357 }); 7358 7359 // Expand/Collapse the main customizer customize info. 7360 $( '.customize-info' ).find( '> .accordion-section-title .customize-help-toggle' ).on( 'click', function() { 7361 var section = $( this ).closest( '.accordion-section' ), 7362 content = section.find( '.customize-panel-description:first' ); 7363 7364 if ( section.hasClass( 'cannot-expand' ) ) { 7365 return; 7366 } 7367 7368 if ( section.hasClass( 'open' ) ) { 7369 section.toggleClass( 'open' ); 7370 content.slideUp( api.Panel.prototype.defaultExpandedArguments.duration, function() { 7371 content.trigger( 'toggled' ); 7372 } ); 7373 $( this ).attr( 'aria-expanded', false ); 7374 } else { 7375 content.slideDown( api.Panel.prototype.defaultExpandedArguments.duration, function() { 7376 content.trigger( 'toggled' ); 7377 } ); 7378 section.toggleClass( 'open' ); 7379 $( this ).attr( 'aria-expanded', true ); 7380 } 7381 }); 7382 7383 /** 7384 * Initialize Previewer 7385 * 7386 * @alias wp.customize.previewer 7387 */ 7388 api.previewer = new api.Previewer({ 7389 container: '#customize-preview', 7390 form: '#customize-controls', 7391 previewUrl: api.settings.url.preview, 7392 allowedUrls: api.settings.url.allowed 7393 },/** @lends wp.customize.previewer */{ 7394 7395 nonce: api.settings.nonce, 7396 7397 /** 7398 * Build the query to send along with the Preview request. 7399 * 7400 * @since 3.4.0 7401 * @since 4.7.0 Added options param. 7402 * @access public 7403 * 7404 * @param {Object} [options] Options. 7405 * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset). 7406 * @return {Object} Query vars. 7407 */ 7408 query: function( options ) { 7409 var queryVars = { 7410 wp_customize: 'on', 7411 customize_theme: api.settings.theme.stylesheet, 7412 nonce: this.nonce.preview, 7413 customize_changeset_uuid: api.settings.changeset.uuid 7414 }; 7415 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { 7416 queryVars.customize_autosaved = 'on'; 7417 } 7418 7419 /* 7420 * Exclude customized data if requested especially for calls to requestChangesetUpdate. 7421 * Changeset updates are differential and so it is a performance waste to send all of 7422 * the dirty settings with each update. 7423 */ 7424 queryVars.customized = JSON.stringify( api.dirtyValues( { 7425 unsaved: options && options.excludeCustomizedSaved 7426 } ) ); 7427 7428 return queryVars; 7429 }, 7430 7431 /** 7432 * Save (and publish) the customizer changeset. 7433 * 7434 * Updates to the changeset are transactional. If any of the settings 7435 * are invalid then none of them will be written into the changeset. 7436 * A revision will be made for the changeset post if revisions support 7437 * has been added to the post type. 7438 * 7439 * @since 3.4.0 7440 * @since 4.7.0 Added args param and return value. 7441 * 7442 * @param {Object} [args] Args. 7443 * @param {string} [args.status=publish] Status. 7444 * @param {string} [args.date] Date, in local time in MySQL format. 7445 * @param {string} [args.title] Title 7446 * @return {jQuery.promise} Promise. 7447 */ 7448 save: function( args ) { 7449 var previewer = this, 7450 deferred = $.Deferred(), 7451 changesetStatus = api.state( 'selectedChangesetStatus' ).get(), 7452 selectedChangesetDate = api.state( 'selectedChangesetDate' ).get(), 7453 processing = api.state( 'processing' ), 7454 submitWhenDoneProcessing, 7455 submit, 7456 modifiedWhileSaving = {}, 7457 invalidSettings = [], 7458 invalidControls = [], 7459 invalidSettingLessControls = []; 7460 7461 if ( args && args.status ) { 7462 changesetStatus = args.status; 7463 } 7464 7465 if ( api.state( 'saving' ).get() ) { 7466 deferred.reject( 'already_saving' ); 7467 deferred.promise(); 7468 } 7469 7470 api.state( 'saving' ).set( true ); 7471 7472 function captureSettingModifiedDuringSave( setting ) { 7473 modifiedWhileSaving[ setting.id ] = true; 7474 } 7475 7476 submit = function () { 7477 var request, query, settingInvalidities = {}, latestRevision = api._latestRevision, errorCode = 'client_side_error'; 7478 7479 api.bind( 'change', captureSettingModifiedDuringSave ); 7480 api.notifications.remove( errorCode ); 7481 7482 /* 7483 * Block saving if there are any settings that are marked as 7484 * invalid from the client (not from the server). Focus on 7485 * the control. 7486 */ 7487 api.each( function( setting ) { 7488 setting.notifications.each( function( notification ) { 7489 if ( 'error' === notification.type && ! notification.fromServer ) { 7490 invalidSettings.push( setting.id ); 7491 if ( ! settingInvalidities[ setting.id ] ) { 7492 settingInvalidities[ setting.id ] = {}; 7493 } 7494 settingInvalidities[ setting.id ][ notification.code ] = notification; 7495 } 7496 } ); 7497 } ); 7498 7499 // Find all invalid setting less controls with notification type error. 7500 api.control.each( function( control ) { 7501 if ( ! control.setting || ! control.setting.id && control.active.get() ) { 7502 control.notifications.each( function( notification ) { 7503 if ( 'error' === notification.type ) { 7504 invalidSettingLessControls.push( [ control ] ); 7505 } 7506 } ); 7507 } 7508 } ); 7509 7510 invalidControls = _.union( invalidSettingLessControls, _.values( api.findControlsForSettings( invalidSettings ) ) ); 7511 if ( ! _.isEmpty( invalidControls ) ) { 7512 7513 invalidControls[0][0].focus(); 7514 api.unbind( 'change', captureSettingModifiedDuringSave ); 7515 7516 if ( invalidSettings.length ) { 7517 api.notifications.add( new api.Notification( errorCode, { 7518 message: ( 1 === invalidSettings.length ? api.l10n.saveBlockedError.singular : api.l10n.saveBlockedError.plural ).replace( /%s/g, String( invalidSettings.length ) ), 7519 type: 'error', 7520 dismissible: true, 7521 saveFailure: true 7522 } ) ); 7523 } 7524 7525 deferred.rejectWith( previewer, [ 7526 { setting_invalidities: settingInvalidities } 7527 ] ); 7528 api.state( 'saving' ).set( false ); 7529 return deferred.promise(); 7530 } 7531 7532 /* 7533 * Note that excludeCustomizedSaved is intentionally false so that the entire 7534 * set of customized data will be included if bypassed changeset update. 7535 */ 7536 query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), { 7537 nonce: previewer.nonce.save, 7538 customize_changeset_status: changesetStatus 7539 } ); 7540 7541 if ( args && args.date ) { 7542 query.customize_changeset_date = args.date; 7543 } else if ( 'future' === changesetStatus && selectedChangesetDate ) { 7544 query.customize_changeset_date = selectedChangesetDate; 7545 } 7546 7547 if ( args && args.title ) { 7548 query.customize_changeset_title = args.title; 7549 } 7550 7551 // Allow plugins to modify the params included with the save request. 7552 api.trigger( 'save-request-params', query ); 7553 7554 /* 7555 * Note that the dirty customized values will have already been set in the 7556 * changeset and so technically query.customized could be deleted. However, 7557 * it is remaining here to make sure that any settings that got updated 7558 * quietly which may have not triggered an update request will also get 7559 * included in the values that get saved to the changeset. This will ensure 7560 * that values that get injected via the saved event will be included in 7561 * the changeset. This also ensures that setting values that were invalid 7562 * will get re-validated, perhaps in the case of settings that are invalid 7563 * due to dependencies on other settings. 7564 */ 7565 request = wp.ajax.post( 'customize_save', query ); 7566 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 7567 7568 api.trigger( 'save', request ); 7569 7570 request.always( function () { 7571 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 7572 api.state( 'saving' ).set( false ); 7573 api.unbind( 'change', captureSettingModifiedDuringSave ); 7574 } ); 7575 7576 // Remove notifications that were added due to save failures. 7577 api.notifications.each( function( notification ) { 7578 if ( notification.saveFailure ) { 7579 api.notifications.remove( notification.code ); 7580 } 7581 }); 7582 7583 request.fail( function ( response ) { 7584 var notification, notificationArgs; 7585 notificationArgs = { 7586 type: 'error', 7587 dismissible: true, 7588 fromServer: true, 7589 saveFailure: true 7590 }; 7591 7592 if ( '0' === response ) { 7593 response = 'not_logged_in'; 7594 } else if ( '-1' === response ) { 7595 // Back-compat in case any other check_ajax_referer() call is dying. 7596 response = 'invalid_nonce'; 7597 } 7598 7599 if ( 'invalid_nonce' === response ) { 7600 previewer.cheatin(); 7601 } else if ( 'not_logged_in' === response ) { 7602 previewer.preview.iframe.hide(); 7603 previewer.login().done( function() { 7604 previewer.save(); 7605 previewer.preview.iframe.show(); 7606 } ); 7607 } else if ( response.code ) { 7608 if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) { 7609 api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus(); 7610 } else if ( 'changeset_locked' !== response.code ) { 7611 notification = new api.Notification( response.code, _.extend( notificationArgs, { 7612 message: response.message 7613 } ) ); 7614 } 7615 } else { 7616 notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, { 7617 message: api.l10n.unknownRequestFail 7618 } ) ); 7619 } 7620 7621 if ( notification ) { 7622 api.notifications.add( notification ); 7623 } 7624 7625 if ( response.setting_validities ) { 7626 api._handleSettingValidities( { 7627 settingValidities: response.setting_validities, 7628 focusInvalidControl: true 7629 } ); 7630 } 7631 7632 deferred.rejectWith( previewer, [ response ] ); 7633 api.trigger( 'error', response ); 7634 7635 // Start a new changeset if the underlying changeset was published. 7636 if ( 'changeset_already_published' === response.code && response.next_changeset_uuid ) { 7637 api.settings.changeset.uuid = response.next_changeset_uuid; 7638 api.state( 'changesetStatus' ).set( '' ); 7639 if ( api.settings.changeset.branching ) { 7640 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 7641 } 7642 api.previewer.send( 'changeset-uuid', api.settings.changeset.uuid ); 7643 } 7644 } ); 7645 7646 request.done( function( response ) { 7647 7648 previewer.send( 'saved', response ); 7649 7650 api.state( 'changesetStatus' ).set( response.changeset_status ); 7651 if ( response.changeset_date ) { 7652 api.state( 'changesetDate' ).set( response.changeset_date ); 7653 } 7654 7655 if ( 'publish' === response.changeset_status ) { 7656 7657 // Mark all published as clean if they haven't been modified during the request. 7658 api.each( function( setting ) { 7659 /* 7660 * Note that the setting revision will be undefined in the case of setting 7661 * values that are marked as dirty when the customizer is loaded, such as 7662 * when applying starter content. All other dirty settings will have an 7663 * associated revision due to their modification triggering a change event. 7664 */ 7665 if ( setting._dirty && ( _.isUndefined( api._latestSettingRevisions[ setting.id ] ) || api._latestSettingRevisions[ setting.id ] <= latestRevision ) ) { 7666 setting._dirty = false; 7667 } 7668 } ); 7669 7670 api.state( 'changesetStatus' ).set( '' ); 7671 api.settings.changeset.uuid = response.next_changeset_uuid; 7672 if ( api.settings.changeset.branching ) { 7673 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 7674 } 7675 } 7676 7677 // Prevent subsequent requestChangesetUpdate() calls from including the settings that have been saved. 7678 api._lastSavedRevision = Math.max( latestRevision, api._lastSavedRevision ); 7679 7680 if ( response.setting_validities ) { 7681 api._handleSettingValidities( { 7682 settingValidities: response.setting_validities, 7683 focusInvalidControl: true 7684 } ); 7685 } 7686 7687 deferred.resolveWith( previewer, [ response ] ); 7688 api.trigger( 'saved', response ); 7689 7690 // Restore the global dirty state if any settings were modified during save. 7691 if ( ! _.isEmpty( modifiedWhileSaving ) ) { 7692 api.state( 'saved' ).set( false ); 7693 } 7694 } ); 7695 }; 7696 7697 if ( 0 === processing() ) { 7698 submit(); 7699 } else { 7700 submitWhenDoneProcessing = function () { 7701 if ( 0 === processing() ) { 7702 api.state.unbind( 'change', submitWhenDoneProcessing ); 7703 submit(); 7704 } 7705 }; 7706 api.state.bind( 'change', submitWhenDoneProcessing ); 7707 } 7708 7709 return deferred.promise(); 7710 }, 7711 7712 /** 7713 * Trash the current changes. 7714 * 7715 * Revert the Customizer to its previously-published state. 7716 * 7717 * @since 4.9.0 7718 * 7719 * @return {jQuery.promise} Promise. 7720 */ 7721 trash: function trash() { 7722 var request, success, fail; 7723 7724 api.state( 'trashing' ).set( true ); 7725 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 7726 7727 request = wp.ajax.post( 'customize_trash', { 7728 customize_changeset_uuid: api.settings.changeset.uuid, 7729 nonce: api.settings.nonce.trash 7730 } ); 7731 api.notifications.add( new api.OverlayNotification( 'changeset_trashing', { 7732 type: 'info', 7733 message: api.l10n.revertingChanges, 7734 loading: true 7735 } ) ); 7736 7737 success = function() { 7738 var urlParser = document.createElement( 'a' ), queryParams; 7739 7740 api.state( 'changesetStatus' ).set( 'trash' ); 7741 api.each( function( setting ) { 7742 setting._dirty = false; 7743 } ); 7744 api.state( 'saved' ).set( true ); 7745 7746 // Go back to Customizer without changeset. 7747 urlParser.href = location.href; 7748 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 7749 delete queryParams.changeset_uuid; 7750 queryParams['return'] = api.settings.url['return']; 7751 urlParser.search = $.param( queryParams ); 7752 location.replace( urlParser.href ); 7753 }; 7754 7755 fail = function( code, message ) { 7756 var notificationCode = code || 'unknown_error'; 7757 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 7758 api.state( 'trashing' ).set( false ); 7759 api.notifications.remove( 'changeset_trashing' ); 7760 api.notifications.add( new api.Notification( notificationCode, { 7761 message: message || api.l10n.unknownError, 7762 dismissible: true, 7763 type: 'error' 7764 } ) ); 7765 }; 7766 7767 request.done( function( response ) { 7768 success( response.message ); 7769 } ); 7770 7771 request.fail( function( response ) { 7772 var code = response.code || 'trashing_failed'; 7773 if ( response.success || 'non_existent_changeset' === code || 'changeset_already_trashed' === code ) { 7774 success( response.message ); 7775 } else { 7776 fail( code, response.message ); 7777 } 7778 } ); 7779 }, 7780 7781 /** 7782 * Builds the front preview url with the current state of customizer. 7783 * 7784 * @since 4.9 7785 * 7786 * @return {string} Preview url. 7787 */ 7788 getFrontendPreviewUrl: function() { 7789 var previewer = this, params, urlParser; 7790 urlParser = document.createElement( 'a' ); 7791 urlParser.href = previewer.previewUrl.get(); 7792 params = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 7793 7794 if ( api.state( 'changesetStatus' ).get() && 'publish' !== api.state( 'changesetStatus' ).get() ) { 7795 params.customize_changeset_uuid = api.settings.changeset.uuid; 7796 } 7797 if ( ! api.state( 'activated' ).get() ) { 7798 params.customize_theme = api.settings.theme.stylesheet; 7799 } 7800 7801 urlParser.search = $.param( params ); 7802 return urlParser.href; 7803 } 7804 }); 7805 7806 // Ensure preview nonce is included with every customized request, to allow post data to be read. 7807 $.ajaxPrefilter( function injectPreviewNonce( options ) { 7808 if ( ! /wp_customize=on/.test( options.data ) ) { 7809 return; 7810 } 7811 options.data += '&' + $.param({ 7812 customize_preview_nonce: api.settings.nonce.preview 7813 }); 7814 }); 7815 7816 // Refresh the nonces if the preview sends updated nonces over. 7817 api.previewer.bind( 'nonce', function( nonce ) { 7818 $.extend( this.nonce, nonce ); 7819 }); 7820 7821 // Refresh the nonces if login sends updated nonces over. 7822 api.bind( 'nonce-refresh', function( nonce ) { 7823 $.extend( api.settings.nonce, nonce ); 7824 $.extend( api.previewer.nonce, nonce ); 7825 api.previewer.send( 'nonce-refresh', nonce ); 7826 }); 7827 7828 // Create Settings. 7829 $.each( api.settings.settings, function( id, data ) { 7830 var Constructor = api.settingConstructor[ data.type ] || api.Setting; 7831 api.add( new Constructor( id, data.value, { 7832 transport: data.transport, 7833 previewer: api.previewer, 7834 dirty: !! data.dirty 7835 } ) ); 7836 }); 7837 7838 // Create Panels. 7839 $.each( api.settings.panels, function ( id, data ) { 7840 var Constructor = api.panelConstructor[ data.type ] || api.Panel, options; 7841 // Inclusion of params alias is for back-compat for custom panels that expect to augment this property. 7842 options = _.extend( { params: data }, data ); 7843 api.panel.add( new Constructor( id, options ) ); 7844 }); 7845 7846 // Create Sections. 7847 $.each( api.settings.sections, function ( id, data ) { 7848 var Constructor = api.sectionConstructor[ data.type ] || api.Section, options; 7849 // Inclusion of params alias is for back-compat for custom sections that expect to augment this property. 7850 options = _.extend( { params: data }, data ); 7851 api.section.add( new Constructor( id, options ) ); 7852 }); 7853 7854 // Create Controls. 7855 $.each( api.settings.controls, function( id, data ) { 7856 var Constructor = api.controlConstructor[ data.type ] || api.Control, options; 7857 // Inclusion of params alias is for back-compat for custom controls that expect to augment this property. 7858 options = _.extend( { params: data }, data ); 7859 api.control.add( new Constructor( id, options ) ); 7860 }); 7861 7862 // Focus the autofocused element. 7863 _.each( [ 'panel', 'section', 'control' ], function( type ) { 7864 var id = api.settings.autofocus[ type ]; 7865 if ( ! id ) { 7866 return; 7867 } 7868 7869 /* 7870 * Defer focus until: 7871 * 1. The panel, section, or control exists (especially for dynamically-created ones). 7872 * 2. The instance is embedded in the document (and so is focusable). 7873 * 3. The preview has finished loading so that the active states have been set. 7874 */ 7875 api[ type ]( id, function( instance ) { 7876 instance.deferred.embedded.done( function() { 7877 api.previewer.deferred.active.done( function() { 7878 instance.focus(); 7879 }); 7880 }); 7881 }); 7882 }); 7883 7884 api.bind( 'ready', api.reflowPaneContents ); 7885 $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) { 7886 var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents ); 7887 values.bind( 'add', debouncedReflowPaneContents ); 7888 values.bind( 'change', debouncedReflowPaneContents ); 7889 values.bind( 'remove', debouncedReflowPaneContents ); 7890 } ); 7891 7892 // Set up global notifications area. 7893 api.bind( 'ready', function setUpGlobalNotificationsArea() { 7894 var sidebar, containerHeight, containerInitialTop; 7895 api.notifications.container = $( '#customize-notifications-area' ); 7896 7897 api.notifications.bind( 'change', _.debounce( function() { 7898 api.notifications.render(); 7899 } ) ); 7900 7901 sidebar = $( '.wp-full-overlay-sidebar-content' ); 7902 api.notifications.bind( 'rendered', function updateSidebarTop() { 7903 sidebar.css( 'top', '' ); 7904 if ( 0 !== api.notifications.count() ) { 7905 containerHeight = api.notifications.container.outerHeight() + 1; 7906 containerInitialTop = parseInt( sidebar.css( 'top' ), 10 ); 7907 sidebar.css( 'top', containerInitialTop + containerHeight + 'px' ); 7908 } 7909 api.notifications.trigger( 'sidebarTopUpdated' ); 7910 }); 7911 7912 api.notifications.render(); 7913 }); 7914 7915 // Save and activated states. 7916 (function( state ) { 7917 var saved = state.instance( 'saved' ), 7918 saving = state.instance( 'saving' ), 7919 trashing = state.instance( 'trashing' ), 7920 activated = state.instance( 'activated' ), 7921 processing = state.instance( 'processing' ), 7922 paneVisible = state.instance( 'paneVisible' ), 7923 expandedPanel = state.instance( 'expandedPanel' ), 7924 expandedSection = state.instance( 'expandedSection' ), 7925 changesetStatus = state.instance( 'changesetStatus' ), 7926 selectedChangesetStatus = state.instance( 'selectedChangesetStatus' ), 7927 changesetDate = state.instance( 'changesetDate' ), 7928 selectedChangesetDate = state.instance( 'selectedChangesetDate' ), 7929 previewerAlive = state.instance( 'previewerAlive' ), 7930 editShortcutVisibility = state.instance( 'editShortcutVisibility' ), 7931 changesetLocked = state.instance( 'changesetLocked' ), 7932 populateChangesetUuidParam, defaultSelectedChangesetStatus; 7933 7934 state.bind( 'change', function() { 7935 var canSave; 7936 7937 if ( ! activated() ) { 7938 saveBtn.val( api.l10n.activate ); 7939 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 7940 7941 } else if ( '' === changesetStatus.get() && saved() ) { 7942 if ( api.settings.changeset.currentUserCanPublish ) { 7943 saveBtn.val( api.l10n.published ); 7944 } else { 7945 saveBtn.val( api.l10n.saved ); 7946 } 7947 closeBtn.find( '.screen-reader-text' ).text( api.l10n.close ); 7948 7949 } else { 7950 if ( 'draft' === selectedChangesetStatus() ) { 7951 if ( saved() && selectedChangesetStatus() === changesetStatus() ) { 7952 saveBtn.val( api.l10n.draftSaved ); 7953 } else { 7954 saveBtn.val( api.l10n.saveDraft ); 7955 } 7956 } else if ( 'future' === selectedChangesetStatus() ) { 7957 if ( saved() && selectedChangesetStatus() === changesetStatus() ) { 7958 if ( changesetDate.get() !== selectedChangesetDate.get() ) { 7959 saveBtn.val( api.l10n.schedule ); 7960 } else { 7961 saveBtn.val( api.l10n.scheduled ); 7962 } 7963 } else { 7964 saveBtn.val( api.l10n.schedule ); 7965 } 7966 } else if ( api.settings.changeset.currentUserCanPublish ) { 7967 saveBtn.val( api.l10n.publish ); 7968 } 7969 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 7970 } 7971 7972 /* 7973 * Save (publish) button should be enabled if saving is not currently happening, 7974 * and if the theme is not active or the changeset exists but is not published. 7975 */ 7976 canSave = ! saving() && ! trashing() && ! changesetLocked() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) ); 7977 7978 saveBtn.prop( 'disabled', ! canSave ); 7979 }); 7980 7981 selectedChangesetStatus.validate = function( status ) { 7982 if ( '' === status || 'auto-draft' === status ) { 7983 return null; 7984 } 7985 return status; 7986 }; 7987 7988 defaultSelectedChangesetStatus = api.settings.changeset.currentUserCanPublish ? 'publish' : 'draft'; 7989 7990 // Set default states. 7991 changesetStatus( api.settings.changeset.status ); 7992 changesetLocked( Boolean( api.settings.changeset.lockUser ) ); 7993 changesetDate( api.settings.changeset.publishDate ); 7994 selectedChangesetDate( api.settings.changeset.publishDate ); 7995 selectedChangesetStatus( '' === api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ? defaultSelectedChangesetStatus : api.settings.changeset.status ); 7996 selectedChangesetStatus.link( changesetStatus ); // Ensure that direct updates to status on server via wp.customizer.previewer.save() will update selection. 7997 saved( true ); 7998 if ( '' === changesetStatus() ) { // Handle case for loading starter content. 7999 api.each( function( setting ) { 8000 if ( setting._dirty ) { 8001 saved( false ); 8002 } 8003 } ); 8004 } 8005 saving( false ); 8006 activated( api.settings.theme.active ); 8007 processing( 0 ); 8008 paneVisible( true ); 8009 expandedPanel( false ); 8010 expandedSection( false ); 8011 previewerAlive( true ); 8012 editShortcutVisibility( 'visible' ); 8013 8014 api.bind( 'change', function() { 8015 if ( state( 'saved' ).get() ) { 8016 state( 'saved' ).set( false ); 8017 } 8018 }); 8019 8020 // Populate changeset UUID param when state becomes dirty. 8021 if ( api.settings.changeset.branching ) { 8022 saved.bind( function( isSaved ) { 8023 if ( ! isSaved ) { 8024 populateChangesetUuidParam( true ); 8025 } 8026 }); 8027 } 8028 8029 saving.bind( function( isSaving ) { 8030 body.toggleClass( 'saving', isSaving ); 8031 } ); 8032 trashing.bind( function( isTrashing ) { 8033 body.toggleClass( 'trashing', isTrashing ); 8034 } ); 8035 8036 api.bind( 'saved', function( response ) { 8037 state('saved').set( true ); 8038 if ( 'publish' === response.changeset_status ) { 8039 state( 'activated' ).set( true ); 8040 } 8041 }); 8042 8043 activated.bind( function( to ) { 8044 if ( to ) { 8045 api.trigger( 'activated' ); 8046 } 8047 }); 8048 8049 /** 8050 * Populate URL with UUID via `history.replaceState()`. 8051 * 8052 * @since 4.7.0 8053 * @access private 8054 * 8055 * @param {boolean} isIncluded Is UUID included. 8056 * @return {void} 8057 */ 8058 populateChangesetUuidParam = function( isIncluded ) { 8059 var urlParser, queryParams; 8060 8061 // Abort on IE9 which doesn't support history management. 8062 if ( ! history.replaceState ) { 8063 return; 8064 } 8065 8066 urlParser = document.createElement( 'a' ); 8067 urlParser.href = location.href; 8068 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8069 if ( isIncluded ) { 8070 if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) { 8071 return; 8072 } 8073 queryParams.changeset_uuid = api.settings.changeset.uuid; 8074 } else { 8075 if ( ! queryParams.changeset_uuid ) { 8076 return; 8077 } 8078 delete queryParams.changeset_uuid; 8079 } 8080 urlParser.search = $.param( queryParams ); 8081 history.replaceState( {}, document.title, urlParser.href ); 8082 }; 8083 8084 // Show changeset UUID in URL when in branching mode and there is a saved changeset. 8085 if ( api.settings.changeset.branching ) { 8086 changesetStatus.bind( function( newStatus ) { 8087 populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus && 'trash' !== newStatus ); 8088 } ); 8089 } 8090 }( api.state ) ); 8091 8092 /** 8093 * Handles lock notice and take over request. 8094 * 8095 * @since 4.9.0 8096 */ 8097 ( function checkAndDisplayLockNotice() { 8098 8099 var LockedNotification = api.OverlayNotification.extend(/** @lends wp.customize~LockedNotification.prototype */{ 8100 8101 /** 8102 * Template ID. 8103 * 8104 * @type {string} 8105 */ 8106 templateId: 'customize-changeset-locked-notification', 8107 8108 /** 8109 * Lock user. 8110 * 8111 * @type {object} 8112 */ 8113 lockUser: null, 8114 8115 /** 8116 * A notification that is displayed in a full-screen overlay with information about the locked changeset. 8117 * 8118 * @constructs wp.customize~LockedNotification 8119 * @augments wp.customize.OverlayNotification 8120 * 8121 * @since 4.9.0 8122 * 8123 * @param {string} [code] - Code. 8124 * @param {Object} [params] - Params. 8125 */ 8126 initialize: function( code, params ) { 8127 var notification = this, _code, _params; 8128 _code = code || 'changeset_locked'; 8129 _params = _.extend( 8130 { 8131 message: '', 8132 type: 'warning', 8133 containerClasses: '', 8134 lockUser: {} 8135 }, 8136 params 8137 ); 8138 _params.containerClasses += ' notification-changeset-locked'; 8139 api.OverlayNotification.prototype.initialize.call( notification, _code, _params ); 8140 }, 8141 8142 /** 8143 * Render notification. 8144 * 8145 * @since 4.9.0 8146 * 8147 * @return {jQuery} Notification container. 8148 */ 8149 render: function() { 8150 var notification = this, li, data, takeOverButton, request; 8151 data = _.extend( 8152 { 8153 allowOverride: false, 8154 returnUrl: api.settings.url['return'], 8155 previewUrl: api.previewer.previewUrl.get(), 8156 frontendPreviewUrl: api.previewer.getFrontendPreviewUrl() 8157 }, 8158 this 8159 ); 8160 8161 li = api.OverlayNotification.prototype.render.call( data ); 8162 8163 // Try to autosave the changeset now. 8164 api.requestChangesetUpdate( {}, { autosave: true } ).fail( function( response ) { 8165 if ( ! response.autosaved ) { 8166 li.find( '.notice-error' ).prop( 'hidden', false ).text( response.message || api.l10n.unknownRequestFail ); 8167 } 8168 } ); 8169 8170 takeOverButton = li.find( '.customize-notice-take-over-button' ); 8171 takeOverButton.on( 'click', function( event ) { 8172 event.preventDefault(); 8173 if ( request ) { 8174 return; 8175 } 8176 8177 takeOverButton.addClass( 'disabled' ); 8178 request = wp.ajax.post( 'customize_override_changeset_lock', { 8179 wp_customize: 'on', 8180 customize_theme: api.settings.theme.stylesheet, 8181 customize_changeset_uuid: api.settings.changeset.uuid, 8182 nonce: api.settings.nonce.override_lock 8183 } ); 8184 8185 request.done( function() { 8186 api.notifications.remove( notification.code ); // Remove self. 8187 api.state( 'changesetLocked' ).set( false ); 8188 } ); 8189 8190 request.fail( function( response ) { 8191 var message = response.message || api.l10n.unknownRequestFail; 8192 li.find( '.notice-error' ).prop( 'hidden', false ).text( message ); 8193 8194 request.always( function() { 8195 takeOverButton.removeClass( 'disabled' ); 8196 } ); 8197 } ); 8198 8199 request.always( function() { 8200 request = null; 8201 } ); 8202 } ); 8203 8204 return li; 8205 } 8206 }); 8207 8208 /** 8209 * Start lock. 8210 * 8211 * @since 4.9.0 8212 * 8213 * @param {Object} [args] - Args. 8214 * @param {Object} [args.lockUser] - Lock user data. 8215 * @param {boolean} [args.allowOverride=false] - Whether override is allowed. 8216 * @return {void} 8217 */ 8218 function startLock( args ) { 8219 if ( args && args.lockUser ) { 8220 api.settings.changeset.lockUser = args.lockUser; 8221 } 8222 api.state( 'changesetLocked' ).set( true ); 8223 api.notifications.add( new LockedNotification( 'changeset_locked', { 8224 lockUser: api.settings.changeset.lockUser, 8225 allowOverride: Boolean( args && args.allowOverride ) 8226 } ) ); 8227 } 8228 8229 // Show initial notification. 8230 if ( api.settings.changeset.lockUser ) { 8231 startLock( { allowOverride: true } ); 8232 } 8233 8234 // Check for lock when sending heartbeat requests. 8235 $( document ).on( 'heartbeat-send.update_lock_notice', function( event, data ) { 8236 data.check_changeset_lock = true; 8237 data.changeset_uuid = api.settings.changeset.uuid; 8238 } ); 8239 8240 // Handle heartbeat ticks. 8241 $( document ).on( 'heartbeat-tick.update_lock_notice', function( event, data ) { 8242 var notification, code = 'changeset_locked'; 8243 if ( ! data.customize_changeset_lock_user ) { 8244 return; 8245 } 8246 8247 // Update notification when a different user takes over. 8248 notification = api.notifications( code ); 8249 if ( notification && notification.lockUser.id !== api.settings.changeset.lockUser.id ) { 8250 api.notifications.remove( code ); 8251 } 8252 8253 startLock( { 8254 lockUser: data.customize_changeset_lock_user 8255 } ); 8256 } ); 8257 8258 // Handle locking in response to changeset save errors. 8259 api.bind( 'error', function( response ) { 8260 if ( 'changeset_locked' === response.code && response.lock_user ) { 8261 startLock( { 8262 lockUser: response.lock_user 8263 } ); 8264 } 8265 } ); 8266 } )(); 8267 8268 // Set up initial notifications. 8269 (function() { 8270 var removedQueryParams = [], autosaveDismissed = false; 8271 8272 /** 8273 * Obtain the URL to restore the autosave. 8274 * 8275 * @return {string} Customizer URL. 8276 */ 8277 function getAutosaveRestorationUrl() { 8278 var urlParser, queryParams; 8279 urlParser = document.createElement( 'a' ); 8280 urlParser.href = location.href; 8281 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8282 if ( api.settings.changeset.latestAutoDraftUuid ) { 8283 queryParams.changeset_uuid = api.settings.changeset.latestAutoDraftUuid; 8284 } else { 8285 queryParams.customize_autosaved = 'on'; 8286 } 8287 queryParams['return'] = api.settings.url['return']; 8288 urlParser.search = $.param( queryParams ); 8289 return urlParser.href; 8290 } 8291 8292 /** 8293 * Remove parameter from the URL. 8294 * 8295 * @param {Array} params - Parameter names to remove. 8296 * @return {void} 8297 */ 8298 function stripParamsFromLocation( params ) { 8299 var urlParser = document.createElement( 'a' ), queryParams, strippedParams = 0; 8300 urlParser.href = location.href; 8301 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8302 _.each( params, function( param ) { 8303 if ( 'undefined' !== typeof queryParams[ param ] ) { 8304 strippedParams += 1; 8305 delete queryParams[ param ]; 8306 } 8307 } ); 8308 if ( 0 === strippedParams ) { 8309 return; 8310 } 8311 8312 urlParser.search = $.param( queryParams ); 8313 history.replaceState( {}, document.title, urlParser.href ); 8314 } 8315 8316 /** 8317 * Dismiss autosave. 8318 * 8319 * @return {void} 8320 */ 8321 function dismissAutosave() { 8322 if ( autosaveDismissed ) { 8323 return; 8324 } 8325 wp.ajax.post( 'customize_dismiss_autosave_or_lock', { 8326 wp_customize: 'on', 8327 customize_theme: api.settings.theme.stylesheet, 8328 customize_changeset_uuid: api.settings.changeset.uuid, 8329 nonce: api.settings.nonce.dismiss_autosave_or_lock, 8330 dismiss_autosave: true 8331 } ); 8332 autosaveDismissed = true; 8333 } 8334 8335 /** 8336 * Add notification regarding the availability of an autosave to restore. 8337 * 8338 * @return {void} 8339 */ 8340 function addAutosaveRestoreNotification() { 8341 var code = 'autosave_available', onStateChange; 8342 8343 // Since there is an autosave revision and the user hasn't loaded with autosaved, add notification to prompt to load autosaved version. 8344 api.notifications.add( new api.Notification( code, { 8345 message: api.l10n.autosaveNotice, 8346 type: 'warning', 8347 dismissible: true, 8348 render: function() { 8349 var li = api.Notification.prototype.render.call( this ), link; 8350 8351 // Handle clicking on restoration link. 8352 link = li.find( 'a' ); 8353 link.prop( 'href', getAutosaveRestorationUrl() ); 8354 link.on( 'click', function( event ) { 8355 event.preventDefault(); 8356 location.replace( getAutosaveRestorationUrl() ); 8357 } ); 8358 8359 // Handle dismissal of notice. 8360 li.find( '.notice-dismiss' ).on( 'click', dismissAutosave ); 8361 8362 return li; 8363 } 8364 } ) ); 8365 8366 // Remove the notification once the user starts making changes. 8367 onStateChange = function() { 8368 dismissAutosave(); 8369 api.notifications.remove( code ); 8370 api.unbind( 'change', onStateChange ); 8371 api.state( 'changesetStatus' ).unbind( onStateChange ); 8372 }; 8373 api.bind( 'change', onStateChange ); 8374 api.state( 'changesetStatus' ).bind( onStateChange ); 8375 } 8376 8377 if ( api.settings.changeset.autosaved ) { 8378 api.state( 'saved' ).set( false ); 8379 removedQueryParams.push( 'customize_autosaved' ); 8380 } 8381 if ( ! api.settings.changeset.branching && ( ! api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ) ) { 8382 removedQueryParams.push( 'changeset_uuid' ); // Remove UUID when restoring autosave auto-draft. 8383 } 8384 if ( removedQueryParams.length > 0 ) { 8385 stripParamsFromLocation( removedQueryParams ); 8386 } 8387 if ( api.settings.changeset.latestAutoDraftUuid || api.settings.changeset.hasAutosaveRevision ) { 8388 addAutosaveRestoreNotification(); 8389 } 8390 })(); 8391 8392 // Check if preview url is valid and load the preview frame. 8393 if ( api.previewer.previewUrl() ) { 8394 api.previewer.refresh(); 8395 } else { 8396 api.previewer.previewUrl( api.settings.url.home ); 8397 } 8398 8399 // Button bindings. 8400 saveBtn.on( 'click', function( event ) { 8401 api.previewer.save(); 8402 event.preventDefault(); 8403 }).on( 'keydown', function( event ) { 8404 if ( 9 === event.which ) { // Tab. 8405 return; 8406 } 8407 if ( 13 === event.which ) { // Enter. 8408 api.previewer.save(); 8409 } 8410 event.preventDefault(); 8411 }); 8412 8413 closeBtn.on( 'keydown', function( event ) { 8414 if ( 9 === event.which ) { // Tab. 8415 return; 8416 } 8417 if ( 13 === event.which ) { // Enter. 8418 this.click(); 8419 } 8420 event.preventDefault(); 8421 }); 8422 8423 $( '.collapse-sidebar' ).on( 'click', function() { 8424 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() ); 8425 }); 8426 8427 api.state( 'paneVisible' ).bind( function( paneVisible ) { 8428 overlay.toggleClass( 'preview-only', ! paneVisible ); 8429 overlay.toggleClass( 'expanded', paneVisible ); 8430 overlay.toggleClass( 'collapsed', ! paneVisible ); 8431 8432 if ( ! paneVisible ) { 8433 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'false', 'aria-label': api.l10n.expandSidebar }); 8434 } else { 8435 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'true', 'aria-label': api.l10n.collapseSidebar }); 8436 } 8437 }); 8438 8439 // Keyboard shortcuts - esc to exit section/panel. 8440 body.on( 'keydown', function( event ) { 8441 var collapsedObject, expandedControls = [], expandedSections = [], expandedPanels = []; 8442 8443 if ( 27 !== event.which ) { // Esc. 8444 return; 8445 } 8446 8447 /* 8448 * Abort if the event target is not the body (the default) and not inside of #customize-controls. 8449 * This ensures that ESC meant to collapse a modal dialog or a TinyMCE toolbar won't collapse something else. 8450 */ 8451 if ( ! $( event.target ).is( 'body' ) && ! $.contains( $( '#customize-controls' )[0], event.target ) ) { 8452 return; 8453 } 8454 8455 // Abort if we're inside of a block editor instance. 8456 if ( event.target.closest( '.block-editor-writing-flow' ) !== null || 8457 event.target.closest( '.block-editor-block-list__block-popover' ) !== null 8458 ) { 8459 return; 8460 } 8461 8462 // Check for expanded expandable controls (e.g. widgets and nav menus items), sections, and panels. 8463 api.control.each( function( control ) { 8464 if ( control.expanded && control.expanded() && _.isFunction( control.collapse ) ) { 8465 expandedControls.push( control ); 8466 } 8467 }); 8468 api.section.each( function( section ) { 8469 if ( section.expanded() ) { 8470 expandedSections.push( section ); 8471 } 8472 }); 8473 api.panel.each( function( panel ) { 8474 if ( panel.expanded() ) { 8475 expandedPanels.push( panel ); 8476 } 8477 }); 8478 8479 // Skip collapsing expanded controls if there are no expanded sections. 8480 if ( expandedControls.length > 0 && 0 === expandedSections.length ) { 8481 expandedControls.length = 0; 8482 } 8483 8484 // Collapse the most granular expanded object. 8485 collapsedObject = expandedControls[0] || expandedSections[0] || expandedPanels[0]; 8486 if ( collapsedObject ) { 8487 if ( 'themes' === collapsedObject.params.type ) { 8488 8489 // Themes panel or section. 8490 if ( body.hasClass( 'modal-open' ) ) { 8491 collapsedObject.closeDetails(); 8492 } else if ( api.panel.has( 'themes' ) ) { 8493 8494 // If we're collapsing a section, collapse the panel also. 8495 api.panel( 'themes' ).collapse(); 8496 } 8497 return; 8498 } 8499 collapsedObject.collapse(); 8500 event.preventDefault(); 8501 } 8502 }); 8503 8504 $( '.customize-controls-preview-toggle' ).on( 'click', function() { 8505 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() ); 8506 }); 8507 8508 /* 8509 * Sticky header feature. 8510 */ 8511 (function initStickyHeaders() { 8512 var parentContainer = $( '.wp-full-overlay-sidebar-content' ), 8513 changeContainer, updateHeaderHeight, releaseStickyHeader, resetStickyHeader, positionStickyHeader, 8514 activeHeader, lastScrollTop; 8515 8516 /** 8517 * Determine which panel or section is currently expanded. 8518 * 8519 * @since 4.7.0 8520 * @access private 8521 * 8522 * @param {wp.customize.Panel|wp.customize.Section} container Construct. 8523 * @return {void} 8524 */ 8525 changeContainer = function( container ) { 8526 var newInstance = container, 8527 expandedSection = api.state( 'expandedSection' ).get(), 8528 expandedPanel = api.state( 'expandedPanel' ).get(), 8529 headerElement; 8530 8531 if ( activeHeader && activeHeader.element ) { 8532 // Release previously active header element. 8533 releaseStickyHeader( activeHeader.element ); 8534 8535 // Remove event listener in the previous panel or section. 8536 activeHeader.element.find( '.description' ).off( 'toggled', updateHeaderHeight ); 8537 } 8538 8539 if ( ! newInstance ) { 8540 if ( ! expandedSection && expandedPanel && expandedPanel.contentContainer ) { 8541 newInstance = expandedPanel; 8542 } else if ( ! expandedPanel && expandedSection && expandedSection.contentContainer ) { 8543 newInstance = expandedSection; 8544 } else { 8545 activeHeader = false; 8546 return; 8547 } 8548 } 8549 8550 headerElement = newInstance.contentContainer.find( '.customize-section-title, .panel-meta' ).first(); 8551 if ( headerElement.length ) { 8552 activeHeader = { 8553 instance: newInstance, 8554 element: headerElement, 8555 parent: headerElement.closest( '.customize-pane-child' ), 8556 height: headerElement.outerHeight() 8557 }; 8558 8559 // Update header height whenever help text is expanded or collapsed. 8560 activeHeader.element.find( '.description' ).on( 'toggled', updateHeaderHeight ); 8561 8562 if ( expandedSection ) { 8563 resetStickyHeader( activeHeader.element, activeHeader.parent ); 8564 } 8565 } else { 8566 activeHeader = false; 8567 } 8568 }; 8569 api.state( 'expandedSection' ).bind( changeContainer ); 8570 api.state( 'expandedPanel' ).bind( changeContainer ); 8571 8572 // Throttled scroll event handler. 8573 parentContainer.on( 'scroll', _.throttle( function() { 8574 if ( ! activeHeader ) { 8575 return; 8576 } 8577 8578 var scrollTop = parentContainer.scrollTop(), 8579 scrollDirection; 8580 8581 if ( ! lastScrollTop ) { 8582 scrollDirection = 1; 8583 } else { 8584 if ( scrollTop === lastScrollTop ) { 8585 scrollDirection = 0; 8586 } else if ( scrollTop > lastScrollTop ) { 8587 scrollDirection = 1; 8588 } else { 8589 scrollDirection = -1; 8590 } 8591 } 8592 lastScrollTop = scrollTop; 8593 if ( 0 !== scrollDirection ) { 8594 positionStickyHeader( activeHeader, scrollTop, scrollDirection ); 8595 } 8596 }, 8 ) ); 8597 8598 // Update header position on sidebar layout change. 8599 api.notifications.bind( 'sidebarTopUpdated', function() { 8600 if ( activeHeader && activeHeader.element.hasClass( 'is-sticky' ) ) { 8601 activeHeader.element.css( 'top', parentContainer.css( 'top' ) ); 8602 } 8603 }); 8604 8605 // Release header element if it is sticky. 8606 releaseStickyHeader = function( headerElement ) { 8607 if ( ! headerElement.hasClass( 'is-sticky' ) ) { 8608 return; 8609 } 8610 headerElement 8611 .removeClass( 'is-sticky' ) 8612 .addClass( 'maybe-sticky is-in-view' ) 8613 .css( 'top', parentContainer.scrollTop() + 'px' ); 8614 }; 8615 8616 // Reset position of the sticky header. 8617 resetStickyHeader = function( headerElement, headerParent ) { 8618 if ( headerElement.hasClass( 'is-in-view' ) ) { 8619 headerElement 8620 .removeClass( 'maybe-sticky is-in-view' ) 8621 .css( { 8622 width: '', 8623 top: '' 8624 } ); 8625 headerParent.css( 'padding-top', '' ); 8626 } 8627 }; 8628 8629 /** 8630 * Update active header height. 8631 * 8632 * @since 4.7.0 8633 * @access private 8634 * 8635 * @return {void} 8636 */ 8637 updateHeaderHeight = function() { 8638 activeHeader.height = activeHeader.element.outerHeight(); 8639 }; 8640 8641 /** 8642 * Reposition header on throttled `scroll` event. 8643 * 8644 * @since 4.7.0 8645 * @access private 8646 * 8647 * @param {Object} header - Header. 8648 * @param {number} scrollTop - Scroll top. 8649 * @param {number} scrollDirection - Scroll direction, negative number being up and positive being down. 8650 * @return {void} 8651 */ 8652 positionStickyHeader = function( header, scrollTop, scrollDirection ) { 8653 var headerElement = header.element, 8654 headerParent = header.parent, 8655 headerHeight = header.height, 8656 headerTop = parseInt( headerElement.css( 'top' ), 10 ), 8657 maybeSticky = headerElement.hasClass( 'maybe-sticky' ), 8658 isSticky = headerElement.hasClass( 'is-sticky' ), 8659 isInView = headerElement.hasClass( 'is-in-view' ), 8660 isScrollingUp = ( -1 === scrollDirection ); 8661 8662 // When scrolling down, gradually hide sticky header. 8663 if ( ! isScrollingUp ) { 8664 if ( isSticky ) { 8665 headerTop = scrollTop; 8666 headerElement 8667 .removeClass( 'is-sticky' ) 8668 .css( { 8669 top: headerTop + 'px', 8670 width: '' 8671 } ); 8672 } 8673 if ( isInView && scrollTop > headerTop + headerHeight ) { 8674 headerElement.removeClass( 'is-in-view' ); 8675 headerParent.css( 'padding-top', '' ); 8676 } 8677 return; 8678 } 8679 8680 // Scrolling up. 8681 if ( ! maybeSticky && scrollTop >= headerHeight ) { 8682 maybeSticky = true; 8683 headerElement.addClass( 'maybe-sticky' ); 8684 } else if ( 0 === scrollTop ) { 8685 // Reset header in base position. 8686 headerElement 8687 .removeClass( 'maybe-sticky is-in-view is-sticky' ) 8688 .css( { 8689 top: '', 8690 width: '' 8691 } ); 8692 headerParent.css( 'padding-top', '' ); 8693 return; 8694 } 8695 8696 if ( isInView && ! isSticky ) { 8697 // Header is in the view but is not yet sticky. 8698 if ( headerTop >= scrollTop ) { 8699 // Header is fully visible. 8700 headerElement 8701 .addClass( 'is-sticky' ) 8702 .css( { 8703 top: parentContainer.css( 'top' ), 8704 width: headerParent.outerWidth() + 'px' 8705 } ); 8706 } 8707 } else if ( maybeSticky && ! isInView ) { 8708 // Header is out of the view. 8709 headerElement 8710 .addClass( 'is-in-view' ) 8711 .css( 'top', ( scrollTop - headerHeight ) + 'px' ); 8712 headerParent.css( 'padding-top', headerHeight + 'px' ); 8713 } 8714 }; 8715 }()); 8716 8717 // Previewed device bindings. (The api.previewedDevice property 8718 // is how this Value was first introduced, but since it has moved to api.state.) 8719 api.previewedDevice = api.state( 'previewedDevice' ); 8720 8721 // Set the default device. 8722 api.bind( 'ready', function() { 8723 _.find( api.settings.previewableDevices, function( value, key ) { 8724 if ( true === value['default'] ) { 8725 api.previewedDevice.set( key ); 8726 return true; 8727 } 8728 } ); 8729 } ); 8730 8731 // Set the toggled device. 8732 footerActions.find( '.devices button' ).on( 'click', function( event ) { 8733 api.previewedDevice.set( $( event.currentTarget ).data( 'device' ) ); 8734 }); 8735 8736 // Bind device changes. 8737 api.previewedDevice.bind( function( newDevice ) { 8738 var overlay = $( '.wp-full-overlay' ), 8739 devices = ''; 8740 8741 footerActions.find( '.devices button' ) 8742 .removeClass( 'active' ) 8743 .attr( 'aria-pressed', false ); 8744 8745 footerActions.find( '.devices .preview-' + newDevice ) 8746 .addClass( 'active' ) 8747 .attr( 'aria-pressed', true ); 8748 8749 $.each( api.settings.previewableDevices, function( device ) { 8750 devices += ' preview-' + device; 8751 } ); 8752 8753 overlay 8754 .removeClass( devices ) 8755 .addClass( 'preview-' + newDevice ); 8756 } ); 8757 8758 // Bind site title display to the corresponding field. 8759 if ( title.length ) { 8760 api( 'blogname', function( setting ) { 8761 var updateTitle = function() { 8762 var blogTitle = setting() || ''; 8763 title.text( blogTitle.toString().trim() || api.l10n.untitledBlogName ); 8764 }; 8765 setting.bind( updateTitle ); 8766 updateTitle(); 8767 } ); 8768 } 8769 8770 /* 8771 * Create a postMessage connection with a parent frame, 8772 * in case the Customizer frame was opened with the Customize loader. 8773 * 8774 * @see wp.customize.Loader 8775 */ 8776 parent = new api.Messenger({ 8777 url: api.settings.url.parent, 8778 channel: 'loader' 8779 }); 8780 8781 // Handle exiting of Customizer. 8782 (function() { 8783 var isInsideIframe = false; 8784 8785 function isCleanState() { 8786 var defaultChangesetStatus; 8787 8788 /* 8789 * Handle special case of previewing theme switch since some settings (for nav menus and widgets) 8790 * are pre-dirty and non-active themes can only ever be auto-drafts. 8791 */ 8792 if ( ! api.state( 'activated' ).get() ) { 8793 return 0 === api._latestRevision; 8794 } 8795 8796 // Dirty if the changeset status has been changed but not saved yet. 8797 defaultChangesetStatus = api.state( 'changesetStatus' ).get(); 8798 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) { 8799 defaultChangesetStatus = 'publish'; 8800 } 8801 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) { 8802 return false; 8803 } 8804 8805 // Dirty if scheduled but the changeset date hasn't been saved yet. 8806 if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) { 8807 return false; 8808 } 8809 8810 return api.state( 'saved' ).get() && 'auto-draft' !== api.state( 'changesetStatus' ).get(); 8811 } 8812 8813 /* 8814 * If we receive a 'back' event, we're inside an iframe. 8815 * Send any clicks to the 'Return' link to the parent page. 8816 */ 8817 parent.bind( 'back', function() { 8818 isInsideIframe = true; 8819 }); 8820 8821 function startPromptingBeforeUnload() { 8822 api.unbind( 'change', startPromptingBeforeUnload ); 8823 api.state( 'selectedChangesetStatus' ).unbind( startPromptingBeforeUnload ); 8824 api.state( 'selectedChangesetDate' ).unbind( startPromptingBeforeUnload ); 8825 8826 // Prompt user with AYS dialog if leaving the Customizer with unsaved changes. 8827 $( window ).on( 'beforeunload.customize-confirm', function() { 8828 if ( ! isCleanState() && ! api.state( 'changesetLocked' ).get() ) { 8829 setTimeout( function() { 8830 overlay.removeClass( 'customize-loading' ); 8831 }, 1 ); 8832 return api.l10n.saveAlert; 8833 } 8834 }); 8835 } 8836 api.bind( 'change', startPromptingBeforeUnload ); 8837 api.state( 'selectedChangesetStatus' ).bind( startPromptingBeforeUnload ); 8838 api.state( 'selectedChangesetDate' ).bind( startPromptingBeforeUnload ); 8839 8840 function requestClose() { 8841 var clearedToClose = $.Deferred(), dismissAutoSave = false, dismissLock = false; 8842 8843 if ( isCleanState() ) { 8844 dismissLock = true; 8845 } else if ( confirm( api.l10n.saveAlert ) ) { 8846 8847 dismissLock = true; 8848 8849 // Mark all settings as clean to prevent another call to requestChangesetUpdate. 8850 api.each( function( setting ) { 8851 setting._dirty = false; 8852 }); 8853 $( document ).off( 'visibilitychange.wp-customize-changeset-update' ); 8854 $( window ).off( 'beforeunload.wp-customize-changeset-update' ); 8855 8856 closeBtn.css( 'cursor', 'progress' ); 8857 if ( '' !== api.state( 'changesetStatus' ).get() ) { 8858 dismissAutoSave = true; 8859 } 8860 } else { 8861 clearedToClose.reject(); 8862 } 8863 8864 if ( dismissLock || dismissAutoSave ) { 8865 wp.ajax.send( 'customize_dismiss_autosave_or_lock', { 8866 timeout: 500, // Don't wait too long. 8867 data: { 8868 wp_customize: 'on', 8869 customize_theme: api.settings.theme.stylesheet, 8870 customize_changeset_uuid: api.settings.changeset.uuid, 8871 nonce: api.settings.nonce.dismiss_autosave_or_lock, 8872 dismiss_autosave: dismissAutoSave, 8873 dismiss_lock: dismissLock 8874 } 8875 } ).always( function() { 8876 clearedToClose.resolve(); 8877 } ); 8878 } 8879 8880 return clearedToClose.promise(); 8881 } 8882 8883 parent.bind( 'confirm-close', function() { 8884 requestClose().done( function() { 8885 parent.send( 'confirmed-close', true ); 8886 } ).fail( function() { 8887 parent.send( 'confirmed-close', false ); 8888 } ); 8889 } ); 8890 8891 closeBtn.on( 'click.customize-controls-close', function( event ) { 8892 event.preventDefault(); 8893 if ( isInsideIframe ) { 8894 parent.send( 'close' ); // See confirm-close logic above. 8895 } else { 8896 requestClose().done( function() { 8897 $( window ).off( 'beforeunload.customize-confirm' ); 8898 window.location.href = closeBtn.prop( 'href' ); 8899 } ); 8900 } 8901 }); 8902 })(); 8903 8904 // Pass events through to the parent. 8905 $.each( [ 'saved', 'change' ], function ( i, event ) { 8906 api.bind( event, function() { 8907 parent.send( event ); 8908 }); 8909 } ); 8910 8911 // Pass titles to the parent. 8912 api.bind( 'title', function( newTitle ) { 8913 parent.send( 'title', newTitle ); 8914 }); 8915 8916 if ( api.settings.changeset.branching ) { 8917 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 8918 } 8919 8920 // Initialize the connection with the parent frame. 8921 parent.send( 'ready' ); 8922 8923 // Control visibility for default controls. 8924 $.each({ 8925 'background_image': { 8926 controls: [ 'background_preset', 'background_position', 'background_size', 'background_repeat', 'background_attachment' ], 8927 callback: function( to ) { return !! to; } 8928 }, 8929 'show_on_front': { 8930 controls: [ 'page_on_front', 'page_for_posts' ], 8931 callback: function( to ) { return 'page' === to; } 8932 }, 8933 'header_textcolor': { 8934 controls: [ 'header_textcolor' ], 8935 callback: function( to ) { return 'blank' !== to; } 8936 } 8937 }, function( settingId, o ) { 8938 api( settingId, function( setting ) { 8939 $.each( o.controls, function( i, controlId ) { 8940 api.control( controlId, function( control ) { 8941 var visibility = function( to ) { 8942 control.container.toggle( o.callback( to ) ); 8943 }; 8944 8945 visibility( setting.get() ); 8946 setting.bind( visibility ); 8947 }); 8948 }); 8949 }); 8950 }); 8951 8952 api.control( 'background_preset', function( control ) { 8953 var visibility, defaultValues, values, toggleVisibility, updateSettings, preset; 8954 8955 visibility = { // position, size, repeat, attachment. 8956 'default': [ false, false, false, false ], 8957 'fill': [ true, false, false, false ], 8958 'fit': [ true, false, true, false ], 8959 'repeat': [ true, false, false, true ], 8960 'custom': [ true, true, true, true ] 8961 }; 8962 8963 defaultValues = [ 8964 _wpCustomizeBackground.defaults['default-position-x'], 8965 _wpCustomizeBackground.defaults['default-position-y'], 8966 _wpCustomizeBackground.defaults['default-size'], 8967 _wpCustomizeBackground.defaults['default-repeat'], 8968 _wpCustomizeBackground.defaults['default-attachment'] 8969 ]; 8970 8971 values = { // position_x, position_y, size, repeat, attachment. 8972 'default': defaultValues, 8973 'fill': [ 'left', 'top', 'cover', 'no-repeat', 'fixed' ], 8974 'fit': [ 'left', 'top', 'contain', 'no-repeat', 'fixed' ], 8975 'repeat': [ 'left', 'top', 'auto', 'repeat', 'scroll' ] 8976 }; 8977 8978 // @todo These should actually toggle the active state, 8979 // but without the preview overriding the state in data.activeControls. 8980 toggleVisibility = function( preset ) { 8981 _.each( [ 'background_position', 'background_size', 'background_repeat', 'background_attachment' ], function( controlId, i ) { 8982 var control = api.control( controlId ); 8983 if ( control ) { 8984 control.container.toggle( visibility[ preset ][ i ] ); 8985 } 8986 } ); 8987 }; 8988 8989 updateSettings = function( preset ) { 8990 _.each( [ 'background_position_x', 'background_position_y', 'background_size', 'background_repeat', 'background_attachment' ], function( settingId, i ) { 8991 var setting = api( settingId ); 8992 if ( setting ) { 8993 setting.set( values[ preset ][ i ] ); 8994 } 8995 } ); 8996 }; 8997 8998 preset = control.setting.get(); 8999 toggleVisibility( preset ); 9000 9001 control.setting.bind( 'change', function( preset ) { 9002 toggleVisibility( preset ); 9003 if ( 'custom' !== preset ) { 9004 updateSettings( preset ); 9005 } 9006 } ); 9007 } ); 9008 9009 api.control( 'background_repeat', function( control ) { 9010 control.elements[0].unsync( api( 'background_repeat' ) ); 9011 9012 control.element = new api.Element( control.container.find( 'input' ) ); 9013 control.element.set( 'no-repeat' !== control.setting() ); 9014 9015 control.element.bind( function( to ) { 9016 control.setting.set( to ? 'repeat' : 'no-repeat' ); 9017 } ); 9018 9019 control.setting.bind( function( to ) { 9020 control.element.set( 'no-repeat' !== to ); 9021 } ); 9022 } ); 9023 9024 api.control( 'background_attachment', function( control ) { 9025 control.elements[0].unsync( api( 'background_attachment' ) ); 9026 9027 control.element = new api.Element( control.container.find( 'input' ) ); 9028 control.element.set( 'fixed' !== control.setting() ); 9029 9030 control.element.bind( function( to ) { 9031 control.setting.set( to ? 'scroll' : 'fixed' ); 9032 } ); 9033 9034 control.setting.bind( function( to ) { 9035 control.element.set( 'fixed' !== to ); 9036 } ); 9037 } ); 9038 9039 // Juggle the two controls that use header_textcolor. 9040 api.control( 'display_header_text', function( control ) { 9041 var last = ''; 9042 9043 control.elements[0].unsync( api( 'header_textcolor' ) ); 9044 9045 control.element = new api.Element( control.container.find('input') ); 9046 control.element.set( 'blank' !== control.setting() ); 9047 9048 control.element.bind( function( to ) { 9049 if ( ! to ) { 9050 last = api( 'header_textcolor' ).get(); 9051 } 9052 9053 control.setting.set( to ? last : 'blank' ); 9054 }); 9055 9056 control.setting.bind( function( to ) { 9057 control.element.set( 'blank' !== to ); 9058 }); 9059 }); 9060 9061 // Add behaviors to the static front page controls. 9062 api( 'show_on_front', 'page_on_front', 'page_for_posts', function( showOnFront, pageOnFront, pageForPosts ) { 9063 var handleChange = function() { 9064 var setting = this, pageOnFrontId, pageForPostsId, errorCode = 'show_on_front_page_collision'; 9065 pageOnFrontId = parseInt( pageOnFront(), 10 ); 9066 pageForPostsId = parseInt( pageForPosts(), 10 ); 9067 9068 if ( 'page' === showOnFront() ) { 9069 9070 // Change previewed URL to the homepage when changing the page_on_front. 9071 if ( setting === pageOnFront && pageOnFrontId > 0 ) { 9072 api.previewer.previewUrl.set( api.settings.url.home ); 9073 } 9074 9075 // Change the previewed URL to the selected page when changing the page_for_posts. 9076 if ( setting === pageForPosts && pageForPostsId > 0 ) { 9077 api.previewer.previewUrl.set( api.settings.url.home + '?page_id=' + pageForPostsId ); 9078 } 9079 } 9080 9081 // Toggle notification when the homepage and posts page are both set and the same. 9082 if ( 'page' === showOnFront() && pageOnFrontId && pageForPostsId && pageOnFrontId === pageForPostsId ) { 9083 showOnFront.notifications.add( new api.Notification( errorCode, { 9084 type: 'error', 9085 message: api.l10n.pageOnFrontError 9086 } ) ); 9087 } else { 9088 showOnFront.notifications.remove( errorCode ); 9089 } 9090 }; 9091 showOnFront.bind( handleChange ); 9092 pageOnFront.bind( handleChange ); 9093 pageForPosts.bind( handleChange ); 9094 handleChange.call( showOnFront, showOnFront() ); // Make sure initial notification is added after loading existing changeset. 9095 9096 // Move notifications container to the bottom. 9097 api.control( 'show_on_front', function( showOnFrontControl ) { 9098 showOnFrontControl.deferred.embedded.done( function() { 9099 showOnFrontControl.container.append( showOnFrontControl.getNotificationsContainerElement() ); 9100 }); 9101 }); 9102 }); 9103 9104 // Add code editor for Custom CSS. 9105 (function() { 9106 var sectionReady = $.Deferred(); 9107 9108 api.section( 'custom_css', function( section ) { 9109 section.deferred.embedded.done( function() { 9110 if ( section.expanded() ) { 9111 sectionReady.resolve( section ); 9112 } else { 9113 section.expanded.bind( function( isExpanded ) { 9114 if ( isExpanded ) { 9115 sectionReady.resolve( section ); 9116 } 9117 } ); 9118 } 9119 }); 9120 }); 9121 9122 // Set up the section description behaviors. 9123 sectionReady.done( function setupSectionDescription( section ) { 9124 var control = api.control( 'custom_css' ); 9125 9126 // Hide redundant label for visual users. 9127 control.container.find( '.customize-control-title:first' ).addClass( 'screen-reader-text' ); 9128 9129 // Close the section description when clicking the close button. 9130 section.container.find( '.section-description-buttons .section-description-close' ).on( 'click', function() { 9131 section.container.find( '.section-meta .customize-section-description:first' ) 9132 .removeClass( 'open' ) 9133 .slideUp(); 9134 9135 section.container.find( '.customize-help-toggle' ) 9136 .attr( 'aria-expanded', 'false' ) 9137 .focus(); // Avoid focus loss. 9138 }); 9139 9140 // Reveal help text if setting is empty. 9141 if ( control && ! control.setting.get() ) { 9142 section.container.find( '.section-meta .customize-section-description:first' ) 9143 .addClass( 'open' ) 9144 .show() 9145 .trigger( 'toggled' ); 9146 9147 section.container.find( '.customize-help-toggle' ).attr( 'aria-expanded', 'true' ); 9148 } 9149 }); 9150 })(); 9151 9152 // Toggle visibility of Header Video notice when active state change. 9153 api.control( 'header_video', function( headerVideoControl ) { 9154 headerVideoControl.deferred.embedded.done( function() { 9155 var toggleNotice = function() { 9156 var section = api.section( headerVideoControl.section() ), noticeCode = 'video_header_not_available'; 9157 if ( ! section ) { 9158 return; 9159 } 9160 if ( headerVideoControl.active.get() ) { 9161 section.notifications.remove( noticeCode ); 9162 } else { 9163 section.notifications.add( new api.Notification( noticeCode, { 9164 type: 'info', 9165 message: api.l10n.videoHeaderNotice 9166 } ) ); 9167 } 9168 }; 9169 toggleNotice(); 9170 headerVideoControl.active.bind( toggleNotice ); 9171 } ); 9172 } ); 9173 9174 // Update the setting validities. 9175 api.previewer.bind( 'selective-refresh-setting-validities', function handleSelectiveRefreshedSettingValidities( settingValidities ) { 9176 api._handleSettingValidities( { 9177 settingValidities: settingValidities, 9178 focusInvalidControl: false 9179 } ); 9180 } ); 9181 9182 // Focus on the control that is associated with the given setting. 9183 api.previewer.bind( 'focus-control-for-setting', function( settingId ) { 9184 var matchedControls = []; 9185 api.control.each( function( control ) { 9186 var settingIds = _.pluck( control.settings, 'id' ); 9187 if ( -1 !== _.indexOf( settingIds, settingId ) ) { 9188 matchedControls.push( control ); 9189 } 9190 } ); 9191 9192 // Focus on the matched control with the lowest priority (appearing higher). 9193 if ( matchedControls.length ) { 9194 matchedControls.sort( function( a, b ) { 9195 return a.priority() - b.priority(); 9196 } ); 9197 matchedControls[0].focus(); 9198 } 9199 } ); 9200 9201 // Refresh the preview when it requests. 9202 api.previewer.bind( 'refresh', function() { 9203 api.previewer.refresh(); 9204 }); 9205 9206 // Update the edit shortcut visibility state. 9207 api.state( 'paneVisible' ).bind( function( isPaneVisible ) { 9208 var isMobileScreen; 9209 if ( window.matchMedia ) { 9210 isMobileScreen = window.matchMedia( 'screen and ( max-width: 640px )' ).matches; 9211 } else { 9212 isMobileScreen = $( window ).width() <= 640; 9213 } 9214 api.state( 'editShortcutVisibility' ).set( isPaneVisible || isMobileScreen ? 'visible' : 'hidden' ); 9215 } ); 9216 if ( window.matchMedia ) { 9217 window.matchMedia( 'screen and ( max-width: 640px )' ).addListener( function() { 9218 var state = api.state( 'paneVisible' ); 9219 state.callbacks.fireWith( state, [ state.get(), state.get() ] ); 9220 } ); 9221 } 9222 api.previewer.bind( 'edit-shortcut-visibility', function( visibility ) { 9223 api.state( 'editShortcutVisibility' ).set( visibility ); 9224 } ); 9225 api.state( 'editShortcutVisibility' ).bind( function( visibility ) { 9226 api.previewer.send( 'edit-shortcut-visibility', visibility ); 9227 } ); 9228 9229 // Autosave changeset. 9230 function startAutosaving() { 9231 var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false; 9232 9233 api.unbind( 'change', startAutosaving ); // Ensure startAutosaving only fires once. 9234 9235 function onChangeSaved( isSaved ) { 9236 if ( ! isSaved && ! api.settings.changeset.autosaved ) { 9237 api.settings.changeset.autosaved = true; // Once a change is made then autosaving kicks in. 9238 api.previewer.send( 'autosaving' ); 9239 } 9240 } 9241 api.state( 'saved' ).bind( onChangeSaved ); 9242 onChangeSaved( api.state( 'saved' ).get() ); 9243 9244 /** 9245 * Request changeset update and then re-schedule the next changeset update time. 9246 * 9247 * @since 4.7.0 9248 * @private 9249 */ 9250 updateChangesetWithReschedule = function() { 9251 if ( ! updatePending ) { 9252 updatePending = true; 9253 api.requestChangesetUpdate( {}, { autosave: true } ).always( function() { 9254 updatePending = false; 9255 } ); 9256 } 9257 scheduleChangesetUpdate(); 9258 }; 9259 9260 /** 9261 * Schedule changeset update. 9262 * 9263 * @since 4.7.0 9264 * @private 9265 */ 9266 scheduleChangesetUpdate = function() { 9267 clearTimeout( timeoutId ); 9268 timeoutId = setTimeout( function() { 9269 updateChangesetWithReschedule(); 9270 }, api.settings.timeouts.changesetAutoSave ); 9271 }; 9272 9273 // Start auto-save interval for updating changeset. 9274 scheduleChangesetUpdate(); 9275 9276 // Save changeset when focus removed from window. 9277 $( document ).on( 'visibilitychange.wp-customize-changeset-update', function() { 9278 if ( document.hidden ) { 9279 updateChangesetWithReschedule(); 9280 } 9281 } ); 9282 9283 // Save changeset before unloading window. 9284 $( window ).on( 'beforeunload.wp-customize-changeset-update', function() { 9285 updateChangesetWithReschedule(); 9286 } ); 9287 } 9288 api.bind( 'change', startAutosaving ); 9289 9290 // Make sure TinyMCE dialogs appear above Customizer UI. 9291 $( document ).one( 'tinymce-editor-setup', function() { 9292 if ( window.tinymce.ui.FloatPanel && ( ! window.tinymce.ui.FloatPanel.zIndex || window.tinymce.ui.FloatPanel.zIndex < 500001 ) ) { 9293 window.tinymce.ui.FloatPanel.zIndex = 500001; 9294 } 9295 } ); 9296 9297 body.addClass( 'ready' ); 9298 api.trigger( 'ready' ); 9299 }); 9300 9301 })( wp, jQuery );