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