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