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