[ 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, inputError, 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 inputError = control.container.find('.create-item-error'); 4079 title = input.val(); 4080 select = control.container.find( 'select' ); 4081 4082 if ( ! title ) { 4083 container.addClass( 'form-invalid' ); 4084 input.attr('aria-invalid', 'true'); 4085 input.attr('aria-describedby', inputError.attr('id')); 4086 inputError.slideDown( 'fast' ); 4087 wp.a11y.speak( inputError.text() ); 4088 return; 4089 } 4090 4091 container.removeClass( 'form-invalid' ); 4092 input.attr('aria-invalid', 'false'); 4093 input.removeAttr('aria-describedby'); 4094 inputError.hide(); 4095 input.attr( 'disabled', 'disabled' ); 4096 4097 // The menus functions add the page, publish when appropriate, 4098 // and also add the new page to the dropdown-pages controls. 4099 promise = api.Menus.insertAutoDraftPost( { 4100 post_title: title, 4101 post_type: 'page' 4102 } ); 4103 promise.done( function( data ) { 4104 var availableItem, $content, itemTemplate; 4105 4106 // Prepare the new page as an available menu item. 4107 // See api.Menus.submitNew(). 4108 availableItem = new api.Menus.AvailableItemModel( { 4109 'id': 'post-' + data.post_id, // Used for available menu item Backbone models. 4110 'title': title, 4111 'type': 'post_type', 4112 'type_label': api.Menus.data.l10n.page_label, 4113 'object': 'page', 4114 'object_id': data.post_id, 4115 'url': data.url 4116 } ); 4117 4118 // Add the new item to the list of available menu items. 4119 api.Menus.availableMenuItemsPanel.collection.add( availableItem ); 4120 $content = $( '#available-menu-items-post_type-page' ).find( '.available-menu-items-list' ); 4121 itemTemplate = wp.template( 'available-menu-item' ); 4122 $content.prepend( itemTemplate( availableItem.attributes ) ); 4123 4124 // Focus the select control. 4125 select.focus(); 4126 control.setting.set( String( data.post_id ) ); // Triggers a preview refresh and updates the setting. 4127 4128 // Reset the create page form. 4129 container.slideUp( 180 ); 4130 toggle.slideDown( 180 ); 4131 } ); 4132 promise.always( function() { 4133 input.val( '' ).removeAttr( 'disabled' ); 4134 } ); 4135 } 4136 }); 4137 4138 /** 4139 * A colorpicker control. 4140 * 4141 * @class wp.customize.ColorControl 4142 * @augments wp.customize.Control 4143 */ 4144 api.ColorControl = api.Control.extend(/** @lends wp.customize.ColorControl.prototype */{ 4145 ready: function() { 4146 var control = this, 4147 isHueSlider = this.params.mode === 'hue', 4148 updating = false, 4149 picker; 4150 4151 if ( isHueSlider ) { 4152 picker = this.container.find( '.color-picker-hue' ); 4153 picker.val( control.setting() ).wpColorPicker({ 4154 change: function( event, ui ) { 4155 updating = true; 4156 control.setting( ui.color.h() ); 4157 updating = false; 4158 } 4159 }); 4160 } else { 4161 picker = this.container.find( '.color-picker-hex' ); 4162 picker.val( control.setting() ).wpColorPicker({ 4163 change: function() { 4164 updating = true; 4165 control.setting.set( picker.wpColorPicker( 'color' ) ); 4166 updating = false; 4167 }, 4168 clear: function() { 4169 updating = true; 4170 control.setting.set( '' ); 4171 updating = false; 4172 } 4173 }); 4174 } 4175 4176 control.setting.bind( function ( value ) { 4177 // Bail if the update came from the control itself. 4178 if ( updating ) { 4179 return; 4180 } 4181 picker.val( value ); 4182 picker.wpColorPicker( 'color', value ); 4183 } ); 4184 4185 // Collapse color picker when hitting Esc instead of collapsing the current section. 4186 control.container.on( 'keydown', function( event ) { 4187 var pickerContainer; 4188 if ( 27 !== event.which ) { // Esc. 4189 return; 4190 } 4191 pickerContainer = control.container.find( '.wp-picker-container' ); 4192 if ( pickerContainer.hasClass( 'wp-picker-active' ) ) { 4193 picker.wpColorPicker( 'close' ); 4194 control.container.find( '.wp-color-result' ).focus(); 4195 event.stopPropagation(); // Prevent section from being collapsed. 4196 } 4197 } ); 4198 } 4199 }); 4200 4201 /** 4202 * A control that implements the media modal. 4203 * 4204 * @class wp.customize.MediaControl 4205 * @augments wp.customize.Control 4206 */ 4207 api.MediaControl = api.Control.extend(/** @lends wp.customize.MediaControl.prototype */{ 4208 4209 /** 4210 * When the control's DOM structure is ready, 4211 * set up internal event bindings. 4212 */ 4213 ready: function() { 4214 var control = this; 4215 // Shortcut so that we don't have to use _.bind every time we add a callback. 4216 _.bindAll( control, 'restoreDefault', 'removeFile', 'openFrame', 'select', 'pausePlayer' ); 4217 4218 // Bind events, with delegation to facilitate re-rendering. 4219 control.container.on( 'click keydown', '.upload-button', control.openFrame ); 4220 control.container.on( 'click keydown', '.upload-button', control.pausePlayer ); 4221 control.container.on( 'click keydown', '.thumbnail-image img', control.openFrame ); 4222 control.container.on( 'click keydown', '.default-button', control.restoreDefault ); 4223 control.container.on( 'click keydown', '.remove-button', control.pausePlayer ); 4224 control.container.on( 'click keydown', '.remove-button', control.removeFile ); 4225 control.container.on( 'click keydown', '.remove-button', control.cleanupPlayer ); 4226 4227 // Resize the player controls when it becomes visible (ie when section is expanded). 4228 api.section( control.section() ).container 4229 .on( 'expanded', function() { 4230 if ( control.player ) { 4231 control.player.setControlsSize(); 4232 } 4233 }) 4234 .on( 'collapsed', function() { 4235 control.pausePlayer(); 4236 }); 4237 4238 /** 4239 * Set attachment data and render content. 4240 * 4241 * Note that BackgroundImage.prototype.ready applies this ready method 4242 * to itself. Since BackgroundImage is an UploadControl, the value 4243 * is the attachment URL instead of the attachment ID. In this case 4244 * we skip fetching the attachment data because we have no ID available, 4245 * and it is the responsibility of the UploadControl to set the control's 4246 * attachmentData before calling the renderContent method. 4247 * 4248 * @param {number|string} value Attachment 4249 */ 4250 function setAttachmentDataAndRenderContent( value ) { 4251 var hasAttachmentData = $.Deferred(); 4252 4253 if ( control.extended( api.UploadControl ) ) { 4254 hasAttachmentData.resolve(); 4255 } else { 4256 value = parseInt( value, 10 ); 4257 if ( _.isNaN( value ) || value <= 0 ) { 4258 delete control.params.attachment; 4259 hasAttachmentData.resolve(); 4260 } else if ( control.params.attachment && control.params.attachment.id === value ) { 4261 hasAttachmentData.resolve(); 4262 } 4263 } 4264 4265 // Fetch the attachment data. 4266 if ( 'pending' === hasAttachmentData.state() ) { 4267 wp.media.attachment( value ).fetch().done( function() { 4268 control.params.attachment = this.attributes; 4269 hasAttachmentData.resolve(); 4270 4271 // Send attachment information to the preview for possible use in `postMessage` transport. 4272 wp.customize.previewer.send( control.setting.id + '-attachment-data', this.attributes ); 4273 } ); 4274 } 4275 4276 hasAttachmentData.done( function() { 4277 control.renderContent(); 4278 } ); 4279 } 4280 4281 // Ensure attachment data is initially set (for dynamically-instantiated controls). 4282 setAttachmentDataAndRenderContent( control.setting() ); 4283 4284 // Update the attachment data and re-render the control when the setting changes. 4285 control.setting.bind( setAttachmentDataAndRenderContent ); 4286 }, 4287 4288 pausePlayer: function () { 4289 this.player && this.player.pause(); 4290 }, 4291 4292 cleanupPlayer: function () { 4293 this.player && wp.media.mixin.removePlayer( this.player ); 4294 }, 4295 4296 /** 4297 * Open the media modal. 4298 */ 4299 openFrame: function( event ) { 4300 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4301 return; 4302 } 4303 4304 event.preventDefault(); 4305 4306 if ( ! this.frame ) { 4307 this.initFrame(); 4308 } 4309 4310 this.frame.open(); 4311 }, 4312 4313 /** 4314 * Create a media modal select frame, and store it so the instance can be reused when needed. 4315 */ 4316 initFrame: function() { 4317 this.frame = wp.media({ 4318 button: { 4319 text: this.params.button_labels.frame_button 4320 }, 4321 states: [ 4322 new wp.media.controller.Library({ 4323 title: this.params.button_labels.frame_title, 4324 library: wp.media.query({ type: this.params.mime_type }), 4325 multiple: false, 4326 date: false 4327 }) 4328 ] 4329 }); 4330 4331 // When a file is selected, run a callback. 4332 this.frame.on( 'select', this.select ); 4333 }, 4334 4335 /** 4336 * Callback handler for when an attachment is selected in the media modal. 4337 * Gets the selected image information, and sets it within the control. 4338 */ 4339 select: function() { 4340 // Get the attachment from the modal frame. 4341 var node, 4342 attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4343 mejsSettings = window._wpmejsSettings || {}; 4344 4345 this.params.attachment = attachment; 4346 4347 // Set the Customizer setting; the callback takes care of rendering. 4348 this.setting( attachment.id ); 4349 node = this.container.find( 'audio, video' ).get(0); 4350 4351 // Initialize audio/video previews. 4352 if ( node ) { 4353 this.player = new MediaElementPlayer( node, mejsSettings ); 4354 } else { 4355 this.cleanupPlayer(); 4356 } 4357 }, 4358 4359 /** 4360 * Reset the setting to the default value. 4361 */ 4362 restoreDefault: function( event ) { 4363 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4364 return; 4365 } 4366 event.preventDefault(); 4367 4368 this.params.attachment = this.params.defaultAttachment; 4369 this.setting( this.params.defaultAttachment.url ); 4370 }, 4371 4372 /** 4373 * Called when the "Remove" link is clicked. Empties the setting. 4374 * 4375 * @param {Object} event jQuery Event object 4376 */ 4377 removeFile: function( event ) { 4378 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4379 return; 4380 } 4381 event.preventDefault(); 4382 4383 this.params.attachment = {}; 4384 this.setting( '' ); 4385 this.renderContent(); // Not bound to setting change when emptying. 4386 } 4387 }); 4388 4389 /** 4390 * An upload control, which utilizes the media modal. 4391 * 4392 * @class wp.customize.UploadControl 4393 * @augments wp.customize.MediaControl 4394 */ 4395 api.UploadControl = api.MediaControl.extend(/** @lends wp.customize.UploadControl.prototype */{ 4396 4397 /** 4398 * Callback handler for when an attachment is selected in the media modal. 4399 * Gets the selected image information, and sets it within the control. 4400 */ 4401 select: function() { 4402 // Get the attachment from the modal frame. 4403 var node, 4404 attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4405 mejsSettings = window._wpmejsSettings || {}; 4406 4407 this.params.attachment = attachment; 4408 4409 // Set the Customizer setting; the callback takes care of rendering. 4410 this.setting( attachment.url ); 4411 node = this.container.find( 'audio, video' ).get(0); 4412 4413 // Initialize audio/video previews. 4414 if ( node ) { 4415 this.player = new MediaElementPlayer( node, mejsSettings ); 4416 } else { 4417 this.cleanupPlayer(); 4418 } 4419 }, 4420 4421 // @deprecated 4422 success: function() {}, 4423 4424 // @deprecated 4425 removerVisibility: function() {} 4426 }); 4427 4428 /** 4429 * A control for uploading images. 4430 * 4431 * This control no longer needs to do anything more 4432 * than what the upload control does in JS. 4433 * 4434 * @class wp.customize.ImageControl 4435 * @augments wp.customize.UploadControl 4436 */ 4437 api.ImageControl = api.UploadControl.extend(/** @lends wp.customize.ImageControl.prototype */{ 4438 // @deprecated 4439 thumbnailSrc: function() {} 4440 }); 4441 4442 /** 4443 * A control for uploading background images. 4444 * 4445 * @class wp.customize.BackgroundControl 4446 * @augments wp.customize.UploadControl 4447 */ 4448 api.BackgroundControl = api.UploadControl.extend(/** @lends wp.customize.BackgroundControl.prototype */{ 4449 4450 /** 4451 * When the control's DOM structure is ready, 4452 * set up internal event bindings. 4453 */ 4454 ready: function() { 4455 api.UploadControl.prototype.ready.apply( this, arguments ); 4456 }, 4457 4458 /** 4459 * Callback handler for when an attachment is selected in the media modal. 4460 * Does an additional Ajax request for setting the background context. 4461 */ 4462 select: function() { 4463 api.UploadControl.prototype.select.apply( this, arguments ); 4464 4465 wp.ajax.post( 'custom-background-add', { 4466 nonce: _wpCustomizeBackground.nonces.add, 4467 wp_customize: 'on', 4468 customize_theme: api.settings.theme.stylesheet, 4469 attachment_id: this.params.attachment.id 4470 } ); 4471 } 4472 }); 4473 4474 /** 4475 * A control for positioning a background image. 4476 * 4477 * @since 4.7.0 4478 * 4479 * @class wp.customize.BackgroundPositionControl 4480 * @augments wp.customize.Control 4481 */ 4482 api.BackgroundPositionControl = api.Control.extend(/** @lends wp.customize.BackgroundPositionControl.prototype */{ 4483 4484 /** 4485 * Set up control UI once embedded in DOM and settings are created. 4486 * 4487 * @since 4.7.0 4488 * @access public 4489 */ 4490 ready: function() { 4491 var control = this, updateRadios; 4492 4493 control.container.on( 'change', 'input[name="background-position"]', function() { 4494 var position = $( this ).val().split( ' ' ); 4495 control.settings.x( position[0] ); 4496 control.settings.y( position[1] ); 4497 } ); 4498 4499 updateRadios = _.debounce( function() { 4500 var x, y, radioInput, inputValue; 4501 x = control.settings.x.get(); 4502 y = control.settings.y.get(); 4503 inputValue = String( x ) + ' ' + String( y ); 4504 radioInput = control.container.find( 'input[name="background-position"][value="' + inputValue + '"]' ); 4505 radioInput.trigger( 'click' ); 4506 } ); 4507 control.settings.x.bind( updateRadios ); 4508 control.settings.y.bind( updateRadios ); 4509 4510 updateRadios(); // Set initial UI. 4511 } 4512 } ); 4513 4514 /** 4515 * A control for selecting and cropping an image. 4516 * 4517 * @class wp.customize.CroppedImageControl 4518 * @augments wp.customize.MediaControl 4519 */ 4520 api.CroppedImageControl = api.MediaControl.extend(/** @lends wp.customize.CroppedImageControl.prototype */{ 4521 4522 /** 4523 * Open the media modal to the library state. 4524 */ 4525 openFrame: function( event ) { 4526 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4527 return; 4528 } 4529 4530 this.initFrame(); 4531 this.frame.setState( 'library' ).open(); 4532 }, 4533 4534 /** 4535 * Create a media modal select frame, and store it so the instance can be reused when needed. 4536 */ 4537 initFrame: function() { 4538 var l10n = _wpMediaViewsL10n; 4539 4540 this.frame = wp.media({ 4541 button: { 4542 text: l10n.select, 4543 close: false 4544 }, 4545 states: [ 4546 new wp.media.controller.Library({ 4547 title: this.params.button_labels.frame_title, 4548 library: wp.media.query({ type: 'image' }), 4549 multiple: false, 4550 date: false, 4551 priority: 20, 4552 suggestedWidth: this.params.width, 4553 suggestedHeight: this.params.height 4554 }), 4555 new wp.media.controller.CustomizeImageCropper({ 4556 imgSelectOptions: this.calculateImageSelectOptions, 4557 control: this 4558 }) 4559 ] 4560 }); 4561 4562 this.frame.on( 'select', this.onSelect, this ); 4563 this.frame.on( 'cropped', this.onCropped, this ); 4564 this.frame.on( 'skippedcrop', this.onSkippedCrop, this ); 4565 }, 4566 4567 /** 4568 * After an image is selected in the media modal, switch to the cropper 4569 * state if the image isn't the right size. 4570 */ 4571 onSelect: function() { 4572 var attachment = this.frame.state().get( 'selection' ).first().toJSON(); 4573 4574 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) { 4575 this.setImageFromAttachment( attachment ); 4576 this.frame.close(); 4577 } else { 4578 this.frame.setState( 'cropper' ); 4579 } 4580 }, 4581 4582 /** 4583 * After the image has been cropped, apply the cropped image data to the setting. 4584 * 4585 * @param {Object} croppedImage Cropped attachment data. 4586 */ 4587 onCropped: function( croppedImage ) { 4588 this.setImageFromAttachment( croppedImage ); 4589 }, 4590 4591 /** 4592 * Returns a set of options, computed from the attached image data and 4593 * control-specific data, to be fed to the imgAreaSelect plugin in 4594 * wp.media.view.Cropper. 4595 * 4596 * @param {wp.media.model.Attachment} attachment 4597 * @param {wp.media.controller.Cropper} controller 4598 * @return {Object} Options 4599 */ 4600 calculateImageSelectOptions: function( attachment, controller ) { 4601 var control = controller.get( 'control' ), 4602 flexWidth = !! parseInt( control.params.flex_width, 10 ), 4603 flexHeight = !! parseInt( control.params.flex_height, 10 ), 4604 realWidth = attachment.get( 'width' ), 4605 realHeight = attachment.get( 'height' ), 4606 xInit = parseInt( control.params.width, 10 ), 4607 yInit = parseInt( control.params.height, 10 ), 4608 requiredRatio = xInit / yInit, 4609 realRatio = realWidth / realHeight, 4610 xImg = xInit, 4611 yImg = yInit, 4612 x1, y1, imgSelectOptions; 4613 4614 controller.set( 'hasRequiredAspectRatio', control.hasRequiredAspectRatio( requiredRatio, realRatio ) ); 4615 controller.set( 'suggestedCropSize', { width: realWidth, height: realHeight, x1: 0, y1: 0, x2: xInit, y2: yInit } ); 4616 controller.set( 'canSkipCrop', ! control.mustBeCropped( flexWidth, flexHeight, xInit, yInit, realWidth, realHeight ) ); 4617 4618 if ( realRatio > requiredRatio ) { 4619 yInit = realHeight; 4620 xInit = yInit * requiredRatio; 4621 } else { 4622 xInit = realWidth; 4623 yInit = xInit / requiredRatio; 4624 } 4625 4626 x1 = ( realWidth - xInit ) / 2; 4627 y1 = ( realHeight - yInit ) / 2; 4628 4629 imgSelectOptions = { 4630 handles: true, 4631 keys: true, 4632 instance: true, 4633 persistent: true, 4634 imageWidth: realWidth, 4635 imageHeight: realHeight, 4636 minWidth: xImg > xInit ? xInit : xImg, 4637 minHeight: yImg > yInit ? yInit : yImg, 4638 x1: x1, 4639 y1: y1, 4640 x2: xInit + x1, 4641 y2: yInit + y1 4642 }; 4643 4644 if ( flexHeight === false && flexWidth === false ) { 4645 imgSelectOptions.aspectRatio = xInit + ':' + yInit; 4646 } 4647 4648 if ( true === flexHeight ) { 4649 delete imgSelectOptions.minHeight; 4650 imgSelectOptions.maxWidth = realWidth; 4651 } 4652 4653 if ( true === flexWidth ) { 4654 delete imgSelectOptions.minWidth; 4655 imgSelectOptions.maxHeight = realHeight; 4656 } 4657 4658 return imgSelectOptions; 4659 }, 4660 4661 /** 4662 * Return whether the image must be cropped, based on required dimensions. 4663 * 4664 * @param {boolean} flexW Width is flexible. 4665 * @param {boolean} flexH Height is flexible. 4666 * @param {number} dstW Required width. 4667 * @param {number} dstH Required height. 4668 * @param {number} imgW Provided image's width. 4669 * @param {number} imgH Provided image's height. 4670 * @return {boolean} Whether cropping is required. 4671 */ 4672 mustBeCropped: function( flexW, flexH, dstW, dstH, imgW, imgH ) { 4673 if ( true === flexW && true === flexH ) { 4674 return false; 4675 } 4676 4677 if ( true === flexW && dstH === imgH ) { 4678 return false; 4679 } 4680 4681 if ( true === flexH && dstW === imgW ) { 4682 return false; 4683 } 4684 4685 if ( dstW === imgW && dstH === imgH ) { 4686 return false; 4687 } 4688 4689 if ( imgW <= dstW ) { 4690 return false; 4691 } 4692 4693 return true; 4694 }, 4695 4696 /** 4697 * Check if the image's aspect ratio essentially matches the required aspect ratio. 4698 * 4699 * Floating point precision is low, so this allows a small tolerance. This 4700 * tolerance allows for images over 100,000 px on either side to still trigger 4701 * the cropping flow. 4702 * 4703 * @param {number} requiredRatio Required image ratio. 4704 * @param {number} realRatio Provided image ratio. 4705 * @return {boolean} Whether the image has the required aspect ratio. 4706 */ 4707 hasRequiredAspectRatio: function ( requiredRatio, realRatio ) { 4708 if ( Math.abs( requiredRatio - realRatio ) < 0.000001 ) { 4709 return true; 4710 } 4711 4712 return false; 4713 }, 4714 4715 /** 4716 * If cropping was skipped, apply the image data directly to the setting. 4717 */ 4718 onSkippedCrop: function() { 4719 var attachment = this.frame.state().get( 'selection' ).first().toJSON(); 4720 this.setImageFromAttachment( attachment ); 4721 }, 4722 4723 /** 4724 * Updates the setting and re-renders the control UI. 4725 * 4726 * @param {Object} attachment 4727 */ 4728 setImageFromAttachment: function( attachment ) { 4729 this.params.attachment = attachment; 4730 4731 // Set the Customizer setting; the callback takes care of rendering. 4732 this.setting( attachment.id ); 4733 } 4734 }); 4735 4736 /** 4737 * A control for selecting and cropping Site Icons. 4738 * 4739 * @class wp.customize.SiteIconControl 4740 * @augments wp.customize.CroppedImageControl 4741 */ 4742 api.SiteIconControl = api.CroppedImageControl.extend(/** @lends wp.customize.SiteIconControl.prototype */{ 4743 4744 /** 4745 * Create a media modal select frame, and store it so the instance can be reused when needed. 4746 */ 4747 initFrame: function() { 4748 var l10n = _wpMediaViewsL10n; 4749 4750 this.frame = wp.media({ 4751 button: { 4752 text: l10n.select, 4753 close: false 4754 }, 4755 states: [ 4756 new wp.media.controller.Library({ 4757 title: this.params.button_labels.frame_title, 4758 library: wp.media.query({ type: 'image' }), 4759 multiple: false, 4760 date: false, 4761 priority: 20, 4762 suggestedWidth: this.params.width, 4763 suggestedHeight: this.params.height 4764 }), 4765 new wp.media.controller.SiteIconCropper({ 4766 imgSelectOptions: this.calculateImageSelectOptions, 4767 control: this 4768 }) 4769 ] 4770 }); 4771 4772 this.frame.on( 'select', this.onSelect, this ); 4773 this.frame.on( 'cropped', this.onCropped, this ); 4774 this.frame.on( 'skippedcrop', this.onSkippedCrop, this ); 4775 }, 4776 4777 /** 4778 * After an image is selected in the media modal, switch to the cropper 4779 * state if the image isn't the right size. 4780 */ 4781 onSelect: function() { 4782 var attachment = this.frame.state().get( 'selection' ).first().toJSON(), 4783 controller = this; 4784 4785 if ( this.params.width === attachment.width && this.params.height === attachment.height && ! this.params.flex_width && ! this.params.flex_height ) { 4786 wp.ajax.post( 'crop-image', { 4787 nonce: attachment.nonces.edit, 4788 id: attachment.id, 4789 context: 'site-icon', 4790 cropDetails: { 4791 x1: 0, 4792 y1: 0, 4793 width: this.params.width, 4794 height: this.params.height, 4795 dst_width: this.params.width, 4796 dst_height: this.params.height 4797 } 4798 } ).done( function( croppedImage ) { 4799 controller.setImageFromAttachment( croppedImage ); 4800 controller.frame.close(); 4801 } ).fail( function() { 4802 controller.frame.trigger('content:error:crop'); 4803 } ); 4804 } else { 4805 this.frame.setState( 'cropper' ); 4806 } 4807 }, 4808 4809 /** 4810 * Updates the setting and re-renders the control UI. 4811 * 4812 * @param {Object} attachment 4813 */ 4814 setImageFromAttachment: function( attachment ) { 4815 var sizes = [ 'site_icon-32', 'thumbnail', 'full' ], link, 4816 icon; 4817 4818 _.each( sizes, function( size ) { 4819 if ( ! icon && ! _.isUndefined ( attachment.sizes[ size ] ) ) { 4820 icon = attachment.sizes[ size ]; 4821 } 4822 } ); 4823 4824 this.params.attachment = attachment; 4825 4826 // Set the Customizer setting; the callback takes care of rendering. 4827 this.setting( attachment.id ); 4828 4829 if ( ! icon ) { 4830 return; 4831 } 4832 4833 // Update the icon in-browser. 4834 link = $( 'link[rel="icon"][sizes="32x32"]' ); 4835 link.attr( 'href', icon.url ); 4836 }, 4837 4838 /** 4839 * Called when the "Remove" link is clicked. Empties the setting. 4840 * 4841 * @param {Object} event jQuery Event object 4842 */ 4843 removeFile: function( event ) { 4844 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 4845 return; 4846 } 4847 event.preventDefault(); 4848 4849 this.params.attachment = {}; 4850 this.setting( '' ); 4851 this.renderContent(); // Not bound to setting change when emptying. 4852 $( 'link[rel="icon"][sizes="32x32"]' ).attr( 'href', '/favicon.ico' ); // Set to default. 4853 } 4854 }); 4855 4856 /** 4857 * @class wp.customize.HeaderControl 4858 * @augments wp.customize.Control 4859 */ 4860 api.HeaderControl = api.Control.extend(/** @lends wp.customize.HeaderControl.prototype */{ 4861 ready: function() { 4862 this.btnRemove = $('#customize-control-header_image .actions .remove'); 4863 this.btnNew = $('#customize-control-header_image .actions .new'); 4864 4865 _.bindAll(this, 'openMedia', 'removeImage'); 4866 4867 this.btnNew.on( 'click', this.openMedia ); 4868 this.btnRemove.on( 'click', this.removeImage ); 4869 4870 api.HeaderTool.currentHeader = this.getInitialHeaderImage(); 4871 4872 new api.HeaderTool.CurrentView({ 4873 model: api.HeaderTool.currentHeader, 4874 el: '#customize-control-header_image .current .container' 4875 }); 4876 4877 new api.HeaderTool.ChoiceListView({ 4878 collection: api.HeaderTool.UploadsList = new api.HeaderTool.ChoiceList(), 4879 el: '#customize-control-header_image .choices .uploaded .list' 4880 }); 4881 4882 new api.HeaderTool.ChoiceListView({ 4883 collection: api.HeaderTool.DefaultsList = new api.HeaderTool.DefaultsList(), 4884 el: '#customize-control-header_image .choices .default .list' 4885 }); 4886 4887 api.HeaderTool.combinedList = api.HeaderTool.CombinedList = new api.HeaderTool.CombinedList([ 4888 api.HeaderTool.UploadsList, 4889 api.HeaderTool.DefaultsList 4890 ]); 4891 4892 // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme. 4893 wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on'; 4894 wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet; 4895 }, 4896 4897 /** 4898 * Returns a new instance of api.HeaderTool.ImageModel based on the currently 4899 * saved header image (if any). 4900 * 4901 * @since 4.2.0 4902 * 4903 * @return {Object} Options 4904 */ 4905 getInitialHeaderImage: function() { 4906 if ( ! api.get().header_image || ! api.get().header_image_data || _.contains( [ 'remove-header', 'random-default-image', 'random-uploaded-image' ], api.get().header_image ) ) { 4907 return new api.HeaderTool.ImageModel(); 4908 } 4909 4910 // Get the matching uploaded image object. 4911 var currentHeaderObject = _.find( _wpCustomizeHeader.uploads, function( imageObj ) { 4912 return ( imageObj.attachment_id === api.get().header_image_data.attachment_id ); 4913 } ); 4914 // Fall back to raw current header image. 4915 if ( ! currentHeaderObject ) { 4916 currentHeaderObject = { 4917 url: api.get().header_image, 4918 thumbnail_url: api.get().header_image, 4919 attachment_id: api.get().header_image_data.attachment_id 4920 }; 4921 } 4922 4923 return new api.HeaderTool.ImageModel({ 4924 header: currentHeaderObject, 4925 choice: currentHeaderObject.url.split( '/' ).pop() 4926 }); 4927 }, 4928 4929 /** 4930 * Returns a set of options, computed from the attached image data and 4931 * theme-specific data, to be fed to the imgAreaSelect plugin in 4932 * wp.media.view.Cropper. 4933 * 4934 * @param {wp.media.model.Attachment} attachment 4935 * @param {wp.media.controller.Cropper} controller 4936 * @return {Object} Options 4937 */ 4938 calculateImageSelectOptions: function(attachment, controller) { 4939 var xInit = parseInt(_wpCustomizeHeader.data.width, 10), 4940 yInit = parseInt(_wpCustomizeHeader.data.height, 10), 4941 flexWidth = !! parseInt(_wpCustomizeHeader.data['flex-width'], 10), 4942 flexHeight = !! parseInt(_wpCustomizeHeader.data['flex-height'], 10), 4943 ratio, xImg, yImg, realHeight, realWidth, 4944 imgSelectOptions; 4945 4946 realWidth = attachment.get('width'); 4947 realHeight = attachment.get('height'); 4948 4949 this.headerImage = new api.HeaderTool.ImageModel(); 4950 this.headerImage.set({ 4951 themeWidth: xInit, 4952 themeHeight: yInit, 4953 themeFlexWidth: flexWidth, 4954 themeFlexHeight: flexHeight, 4955 imageWidth: realWidth, 4956 imageHeight: realHeight 4957 }); 4958 4959 controller.set( 'canSkipCrop', ! this.headerImage.shouldBeCropped() ); 4960 4961 ratio = xInit / yInit; 4962 xImg = realWidth; 4963 yImg = realHeight; 4964 4965 if ( xImg / yImg > ratio ) { 4966 yInit = yImg; 4967 xInit = yInit * ratio; 4968 } else { 4969 xInit = xImg; 4970 yInit = xInit / ratio; 4971 } 4972 4973 imgSelectOptions = { 4974 handles: true, 4975 keys: true, 4976 instance: true, 4977 persistent: true, 4978 imageWidth: realWidth, 4979 imageHeight: realHeight, 4980 x1: 0, 4981 y1: 0, 4982 x2: xInit, 4983 y2: yInit 4984 }; 4985 4986 if (flexHeight === false && flexWidth === false) { 4987 imgSelectOptions.aspectRatio = xInit + ':' + yInit; 4988 } 4989 if (flexHeight === false ) { 4990 imgSelectOptions.maxHeight = yInit; 4991 } 4992 if (flexWidth === false ) { 4993 imgSelectOptions.maxWidth = xInit; 4994 } 4995 4996 return imgSelectOptions; 4997 }, 4998 4999 /** 5000 * Sets up and opens the Media Manager in order to select an image. 5001 * Depending on both the size of the image and the properties of the 5002 * current theme, a cropping step after selection may be required or 5003 * skippable. 5004 * 5005 * @param {event} event 5006 */ 5007 openMedia: function(event) { 5008 var l10n = _wpMediaViewsL10n; 5009 5010 event.preventDefault(); 5011 5012 this.frame = wp.media({ 5013 button: { 5014 text: l10n.selectAndCrop, 5015 close: false 5016 }, 5017 states: [ 5018 new wp.media.controller.Library({ 5019 title: l10n.chooseImage, 5020 library: wp.media.query({ type: 'image' }), 5021 multiple: false, 5022 date: false, 5023 priority: 20, 5024 suggestedWidth: _wpCustomizeHeader.data.width, 5025 suggestedHeight: _wpCustomizeHeader.data.height 5026 }), 5027 new wp.media.controller.Cropper({ 5028 imgSelectOptions: this.calculateImageSelectOptions 5029 }) 5030 ] 5031 }); 5032 5033 this.frame.on('select', this.onSelect, this); 5034 this.frame.on('cropped', this.onCropped, this); 5035 this.frame.on('skippedcrop', this.onSkippedCrop, this); 5036 5037 this.frame.open(); 5038 }, 5039 5040 /** 5041 * After an image is selected in the media modal, 5042 * switch to the cropper state. 5043 */ 5044 onSelect: function() { 5045 this.frame.setState('cropper'); 5046 }, 5047 5048 /** 5049 * After the image has been cropped, apply the cropped image data to the setting. 5050 * 5051 * @param {Object} croppedImage Cropped attachment data. 5052 */ 5053 onCropped: function(croppedImage) { 5054 var url = croppedImage.url, 5055 attachmentId = croppedImage.attachment_id, 5056 w = croppedImage.width, 5057 h = croppedImage.height; 5058 this.setImageFromURL(url, attachmentId, w, h); 5059 }, 5060 5061 /** 5062 * If cropping was skipped, apply the image data directly to the setting. 5063 * 5064 * @param {Object} selection 5065 */ 5066 onSkippedCrop: function(selection) { 5067 var url = selection.get('url'), 5068 w = selection.get('width'), 5069 h = selection.get('height'); 5070 this.setImageFromURL(url, selection.id, w, h); 5071 }, 5072 5073 /** 5074 * Creates a new wp.customize.HeaderTool.ImageModel from provided 5075 * header image data and inserts it into the user-uploaded headers 5076 * collection. 5077 * 5078 * @param {string} url 5079 * @param {number} attachmentId 5080 * @param {number} width 5081 * @param {number} height 5082 */ 5083 setImageFromURL: function(url, attachmentId, width, height) { 5084 var choice, data = {}; 5085 5086 data.url = url; 5087 data.thumbnail_url = url; 5088 data.timestamp = _.now(); 5089 5090 if (attachmentId) { 5091 data.attachment_id = attachmentId; 5092 } 5093 5094 if (width) { 5095 data.width = width; 5096 } 5097 5098 if (height) { 5099 data.height = height; 5100 } 5101 5102 choice = new api.HeaderTool.ImageModel({ 5103 header: data, 5104 choice: url.split('/').pop() 5105 }); 5106 api.HeaderTool.UploadsList.add(choice); 5107 api.HeaderTool.currentHeader.set(choice.toJSON()); 5108 choice.save(); 5109 choice.importImage(); 5110 }, 5111 5112 /** 5113 * Triggers the necessary events to deselect an image which was set as 5114 * the currently selected one. 5115 */ 5116 removeImage: function() { 5117 api.HeaderTool.currentHeader.trigger('hide'); 5118 api.HeaderTool.CombinedList.trigger('control:removeImage'); 5119 } 5120 5121 }); 5122 5123 /** 5124 * wp.customize.ThemeControl 5125 * 5126 * @class wp.customize.ThemeControl 5127 * @augments wp.customize.Control 5128 */ 5129 api.ThemeControl = api.Control.extend(/** @lends wp.customize.ThemeControl.prototype */{ 5130 5131 touchDrag: false, 5132 screenshotRendered: false, 5133 5134 /** 5135 * @since 4.2.0 5136 */ 5137 ready: function() { 5138 var control = this, panel = api.panel( 'themes' ); 5139 5140 function disableSwitchButtons() { 5141 return ! panel.canSwitchTheme( control.params.theme.id ); 5142 } 5143 5144 // Temporary special function since supplying SFTP credentials does not work yet. See #42184. 5145 function disableInstallButtons() { 5146 return disableSwitchButtons() || false === api.settings.theme._canInstall || true === api.settings.theme._filesystemCredentialsNeeded; 5147 } 5148 function updateButtons() { 5149 control.container.find( 'button.preview, button.preview-theme' ).toggleClass( 'disabled', disableSwitchButtons() ); 5150 control.container.find( 'button.theme-install' ).toggleClass( 'disabled', disableInstallButtons() ); 5151 } 5152 5153 api.state( 'selectedChangesetStatus' ).bind( updateButtons ); 5154 api.state( 'changesetStatus' ).bind( updateButtons ); 5155 updateButtons(); 5156 5157 control.container.on( 'touchmove', '.theme', function() { 5158 control.touchDrag = true; 5159 }); 5160 5161 // Bind details view trigger. 5162 control.container.on( 'click keydown touchend', '.theme', function( event ) { 5163 var section; 5164 if ( api.utils.isKeydownButNotEnterEvent( event ) ) { 5165 return; 5166 } 5167 5168 // Bail if the user scrolled on a touch device. 5169 if ( control.touchDrag === true ) { 5170 return control.touchDrag = false; 5171 } 5172 5173 // Prevent the modal from showing when the user clicks the action button. 5174 if ( $( event.target ).is( '.theme-actions .button, .update-theme' ) ) { 5175 return; 5176 } 5177 5178 event.preventDefault(); // Keep this AFTER the key filter above. 5179 section = api.section( control.section() ); 5180 section.showDetails( control.params.theme, function() { 5181 5182 // Temporary special function since supplying SFTP credentials does not work yet. See #42184. 5183 if ( api.settings.theme._filesystemCredentialsNeeded ) { 5184 section.overlay.find( '.theme-actions .delete-theme' ).remove(); 5185 } 5186 } ); 5187 }); 5188 5189 control.container.on( 'render-screenshot', function() { 5190 var $screenshot = $( this ).find( 'img' ), 5191 source = $screenshot.data( 'src' ); 5192 5193 if ( source ) { 5194 $screenshot.attr( 'src', source ); 5195 } 5196 control.screenshotRendered = true; 5197 }); 5198 }, 5199 5200 /** 5201 * Show or hide the theme based on the presence of the term in the title, description, tags, and author. 5202 * 5203 * @since 4.2.0 5204 * @param {Array} terms - An array of terms to search for. 5205 * @return {boolean} Whether a theme control was activated or not. 5206 */ 5207 filter: function( terms ) { 5208 var control = this, 5209 matchCount = 0, 5210 haystack = control.params.theme.name + ' ' + 5211 control.params.theme.description + ' ' + 5212 control.params.theme.tags + ' ' + 5213 control.params.theme.author + ' '; 5214 haystack = haystack.toLowerCase().replace( '-', ' ' ); 5215 5216 // Back-compat for behavior in WordPress 4.2.0 to 4.8.X. 5217 if ( ! _.isArray( terms ) ) { 5218 terms = [ terms ]; 5219 } 5220 5221 // Always give exact name matches highest ranking. 5222 if ( control.params.theme.name.toLowerCase() === terms.join( ' ' ) ) { 5223 matchCount = 100; 5224 } else { 5225 5226 // Search for and weight (by 10) complete term matches. 5227 matchCount = matchCount + 10 * ( haystack.split( terms.join( ' ' ) ).length - 1 ); 5228 5229 // Search for each term individually (as whole-word and partial match) and sum weighted match counts. 5230 _.each( terms, function( term ) { 5231 matchCount = matchCount + 2 * ( haystack.split( term + ' ' ).length - 1 ); // Whole-word, double-weighted. 5232 matchCount = matchCount + haystack.split( term ).length - 1; // Partial word, to minimize empty intermediate searches while typing. 5233 }); 5234 5235 // Upper limit on match ranking. 5236 if ( matchCount > 99 ) { 5237 matchCount = 99; 5238 } 5239 } 5240 5241 if ( 0 !== matchCount ) { 5242 control.activate(); 5243 control.params.priority = 101 - matchCount; // Sort results by match count. 5244 return true; 5245 } else { 5246 control.deactivate(); // Hide control. 5247 control.params.priority = 101; 5248 return false; 5249 } 5250 }, 5251 5252 /** 5253 * Rerender the theme from its JS template with the installed type. 5254 * 5255 * @since 4.9.0 5256 * 5257 * @return {void} 5258 */ 5259 rerenderAsInstalled: function( installed ) { 5260 var control = this, section; 5261 if ( installed ) { 5262 control.params.theme.type = 'installed'; 5263 } else { 5264 section = api.section( control.params.section ); 5265 control.params.theme.type = section.params.action; 5266 } 5267 control.renderContent(); // Replaces existing content. 5268 control.container.trigger( 'render-screenshot' ); 5269 } 5270 }); 5271 5272 /** 5273 * Class wp.customize.CodeEditorControl 5274 * 5275 * @since 4.9.0 5276 * 5277 * @class wp.customize.CodeEditorControl 5278 * @augments wp.customize.Control 5279 */ 5280 api.CodeEditorControl = api.Control.extend(/** @lends wp.customize.CodeEditorControl.prototype */{ 5281 5282 /** 5283 * Initialize. 5284 * 5285 * @since 4.9.0 5286 * @param {string} id - Unique identifier for the control instance. 5287 * @param {Object} options - Options hash for the control instance. 5288 * @return {void} 5289 */ 5290 initialize: function( id, options ) { 5291 var control = this; 5292 control.deferred = _.extend( control.deferred || {}, { 5293 codemirror: $.Deferred() 5294 } ); 5295 api.Control.prototype.initialize.call( control, id, options ); 5296 5297 // Note that rendering is debounced so the props will be used when rendering happens after add event. 5298 control.notifications.bind( 'add', function( notification ) { 5299 5300 // Skip if control notification is not from setting csslint_error notification. 5301 if ( notification.code !== control.setting.id + ':csslint_error' ) { 5302 return; 5303 } 5304 5305 // Customize the template and behavior of csslint_error notifications. 5306 notification.templateId = 'customize-code-editor-lint-error-notification'; 5307 notification.render = (function( render ) { 5308 return function() { 5309 var li = render.call( this ); 5310 li.find( 'input[type=checkbox]' ).on( 'click', function() { 5311 control.setting.notifications.remove( 'csslint_error' ); 5312 } ); 5313 return li; 5314 }; 5315 })( notification.render ); 5316 } ); 5317 }, 5318 5319 /** 5320 * Initialize the editor when the containing section is ready and expanded. 5321 * 5322 * @since 4.9.0 5323 * @return {void} 5324 */ 5325 ready: function() { 5326 var control = this; 5327 if ( ! control.section() ) { 5328 control.initEditor(); 5329 return; 5330 } 5331 5332 // Wait to initialize editor until section is embedded and expanded. 5333 api.section( control.section(), function( section ) { 5334 section.deferred.embedded.done( function() { 5335 var onceExpanded; 5336 if ( section.expanded() ) { 5337 control.initEditor(); 5338 } else { 5339 onceExpanded = function( isExpanded ) { 5340 if ( isExpanded ) { 5341 control.initEditor(); 5342 section.expanded.unbind( onceExpanded ); 5343 } 5344 }; 5345 section.expanded.bind( onceExpanded ); 5346 } 5347 } ); 5348 } ); 5349 }, 5350 5351 /** 5352 * Initialize editor. 5353 * 5354 * @since 4.9.0 5355 * @return {void} 5356 */ 5357 initEditor: function() { 5358 var control = this, element, editorSettings = false; 5359 5360 // Obtain editorSettings for instantiation. 5361 if ( wp.codeEditor && ( _.isUndefined( control.params.editor_settings ) || false !== control.params.editor_settings ) ) { 5362 5363 // Obtain default editor settings. 5364 editorSettings = wp.codeEditor.defaultSettings ? _.clone( wp.codeEditor.defaultSettings ) : {}; 5365 editorSettings.codemirror = _.extend( 5366 {}, 5367 editorSettings.codemirror, 5368 { 5369 indentUnit: 2, 5370 tabSize: 2 5371 } 5372 ); 5373 5374 // Merge editor_settings param on top of defaults. 5375 if ( _.isObject( control.params.editor_settings ) ) { 5376 _.each( control.params.editor_settings, function( value, key ) { 5377 if ( _.isObject( value ) ) { 5378 editorSettings[ key ] = _.extend( 5379 {}, 5380 editorSettings[ key ], 5381 value 5382 ); 5383 } 5384 } ); 5385 } 5386 } 5387 5388 element = new api.Element( control.container.find( 'textarea' ) ); 5389 control.elements.push( element ); 5390 element.sync( control.setting ); 5391 element.set( control.setting() ); 5392 5393 if ( editorSettings ) { 5394 control.initSyntaxHighlightingEditor( editorSettings ); 5395 } else { 5396 control.initPlainTextareaEditor(); 5397 } 5398 }, 5399 5400 /** 5401 * Make sure editor gets focused when control is focused. 5402 * 5403 * @since 4.9.0 5404 * @param {Object} [params] - Focus params. 5405 * @param {Function} [params.completeCallback] - Function to call when expansion is complete. 5406 * @return {void} 5407 */ 5408 focus: function( params ) { 5409 var control = this, extendedParams = _.extend( {}, params ), originalCompleteCallback; 5410 originalCompleteCallback = extendedParams.completeCallback; 5411 extendedParams.completeCallback = function() { 5412 if ( originalCompleteCallback ) { 5413 originalCompleteCallback(); 5414 } 5415 if ( control.editor ) { 5416 control.editor.codemirror.focus(); 5417 } 5418 }; 5419 api.Control.prototype.focus.call( control, extendedParams ); 5420 }, 5421 5422 /** 5423 * Initialize syntax-highlighting editor. 5424 * 5425 * @since 4.9.0 5426 * @param {Object} codeEditorSettings - Code editor settings. 5427 * @return {void} 5428 */ 5429 initSyntaxHighlightingEditor: function( codeEditorSettings ) { 5430 var control = this, $textarea = control.container.find( 'textarea' ), settings, suspendEditorUpdate = false; 5431 5432 settings = _.extend( {}, codeEditorSettings, { 5433 onTabNext: _.bind( control.onTabNext, control ), 5434 onTabPrevious: _.bind( control.onTabPrevious, control ), 5435 onUpdateErrorNotice: _.bind( control.onUpdateErrorNotice, control ) 5436 }); 5437 5438 control.editor = wp.codeEditor.initialize( $textarea, settings ); 5439 5440 // Improve the editor accessibility. 5441 $( control.editor.codemirror.display.lineDiv ) 5442 .attr({ 5443 role: 'textbox', 5444 'aria-multiline': 'true', 5445 'aria-label': control.params.label, 5446 'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4' 5447 }); 5448 5449 // Focus the editor when clicking on its label. 5450 control.container.find( 'label' ).on( 'click', function() { 5451 control.editor.codemirror.focus(); 5452 }); 5453 5454 /* 5455 * When the CodeMirror instance changes, mirror to the textarea, 5456 * where we have our "true" change event handler bound. 5457 */ 5458 control.editor.codemirror.on( 'change', function( codemirror ) { 5459 suspendEditorUpdate = true; 5460 $textarea.val( codemirror.getValue() ).trigger( 'change' ); 5461 suspendEditorUpdate = false; 5462 }); 5463 5464 // Update CodeMirror when the setting is changed by another plugin. 5465 control.setting.bind( function( value ) { 5466 if ( ! suspendEditorUpdate ) { 5467 control.editor.codemirror.setValue( value ); 5468 } 5469 }); 5470 5471 // Prevent collapsing section when hitting Esc to tab out of editor. 5472 control.editor.codemirror.on( 'keydown', function onKeydown( codemirror, event ) { 5473 var escKeyCode = 27; 5474 if ( escKeyCode === event.keyCode ) { 5475 event.stopPropagation(); 5476 } 5477 }); 5478 5479 control.deferred.codemirror.resolveWith( control, [ control.editor.codemirror ] ); 5480 }, 5481 5482 /** 5483 * Handle tabbing to the field after the editor. 5484 * 5485 * @since 4.9.0 5486 * @return {void} 5487 */ 5488 onTabNext: function onTabNext() { 5489 var control = this, controls, controlIndex, section; 5490 section = api.section( control.section() ); 5491 controls = section.controls(); 5492 controlIndex = controls.indexOf( control ); 5493 if ( controls.length === controlIndex + 1 ) { 5494 $( '#customize-footer-actions .collapse-sidebar' ).trigger( 'focus' ); 5495 } else { 5496 controls[ controlIndex + 1 ].container.find( ':focusable:first' ).focus(); 5497 } 5498 }, 5499 5500 /** 5501 * Handle tabbing to the field before the editor. 5502 * 5503 * @since 4.9.0 5504 * @return {void} 5505 */ 5506 onTabPrevious: function onTabPrevious() { 5507 var control = this, controls, controlIndex, section; 5508 section = api.section( control.section() ); 5509 controls = section.controls(); 5510 controlIndex = controls.indexOf( control ); 5511 if ( 0 === controlIndex ) { 5512 section.contentContainer.find( '.customize-section-title .customize-help-toggle, .customize-section-title .customize-section-description.open .section-description-close' ).last().focus(); 5513 } else { 5514 controls[ controlIndex - 1 ].contentContainer.find( ':focusable:first' ).focus(); 5515 } 5516 }, 5517 5518 /** 5519 * Update error notice. 5520 * 5521 * @since 4.9.0 5522 * @param {Array} errorAnnotations - Error annotations. 5523 * @return {void} 5524 */ 5525 onUpdateErrorNotice: function onUpdateErrorNotice( errorAnnotations ) { 5526 var control = this, message; 5527 control.setting.notifications.remove( 'csslint_error' ); 5528 5529 if ( 0 !== errorAnnotations.length ) { 5530 if ( 1 === errorAnnotations.length ) { 5531 message = api.l10n.customCssError.singular.replace( '%d', '1' ); 5532 } else { 5533 message = api.l10n.customCssError.plural.replace( '%d', String( errorAnnotations.length ) ); 5534 } 5535 control.setting.notifications.add( new api.Notification( 'csslint_error', { 5536 message: message, 5537 type: 'error' 5538 } ) ); 5539 } 5540 }, 5541 5542 /** 5543 * Initialize plain-textarea editor when syntax highlighting is disabled. 5544 * 5545 * @since 4.9.0 5546 * @return {void} 5547 */ 5548 initPlainTextareaEditor: function() { 5549 var control = this, $textarea = control.container.find( 'textarea' ), textarea = $textarea[0]; 5550 5551 $textarea.on( 'blur', function onBlur() { 5552 $textarea.data( 'next-tab-blurs', false ); 5553 } ); 5554 5555 $textarea.on( 'keydown', function onKeydown( event ) { 5556 var selectionStart, selectionEnd, value, tabKeyCode = 9, escKeyCode = 27; 5557 5558 if ( escKeyCode === event.keyCode ) { 5559 if ( ! $textarea.data( 'next-tab-blurs' ) ) { 5560 $textarea.data( 'next-tab-blurs', true ); 5561 event.stopPropagation(); // Prevent collapsing the section. 5562 } 5563 return; 5564 } 5565 5566 // Short-circuit if tab key is not being pressed or if a modifier key *is* being pressed. 5567 if ( tabKeyCode !== event.keyCode || event.ctrlKey || event.altKey || event.shiftKey ) { 5568 return; 5569 } 5570 5571 // Prevent capturing Tab characters if Esc was pressed. 5572 if ( $textarea.data( 'next-tab-blurs' ) ) { 5573 return; 5574 } 5575 5576 selectionStart = textarea.selectionStart; 5577 selectionEnd = textarea.selectionEnd; 5578 value = textarea.value; 5579 5580 if ( selectionStart >= 0 ) { 5581 textarea.value = value.substring( 0, selectionStart ).concat( '\t', value.substring( selectionEnd ) ); 5582 $textarea.selectionStart = textarea.selectionEnd = selectionStart + 1; 5583 } 5584 5585 event.stopPropagation(); 5586 event.preventDefault(); 5587 }); 5588 5589 control.deferred.codemirror.rejectWith( control ); 5590 } 5591 }); 5592 5593 /** 5594 * Class wp.customize.DateTimeControl. 5595 * 5596 * @since 4.9.0 5597 * @class wp.customize.DateTimeControl 5598 * @augments wp.customize.Control 5599 */ 5600 api.DateTimeControl = api.Control.extend(/** @lends wp.customize.DateTimeControl.prototype */{ 5601 5602 /** 5603 * Initialize behaviors. 5604 * 5605 * @since 4.9.0 5606 * @return {void} 5607 */ 5608 ready: function ready() { 5609 var control = this; 5610 5611 control.inputElements = {}; 5612 control.invalidDate = false; 5613 5614 _.bindAll( control, 'populateSetting', 'updateDaysForMonth', 'populateDateInputs' ); 5615 5616 if ( ! control.setting ) { 5617 throw new Error( 'Missing setting' ); 5618 } 5619 5620 control.container.find( '.date-input' ).each( function() { 5621 var input = $( this ), component, element; 5622 component = input.data( 'component' ); 5623 element = new api.Element( input ); 5624 control.inputElements[ component ] = element; 5625 control.elements.push( element ); 5626 5627 // Add invalid date error once user changes (and has blurred the input). 5628 input.on( 'change', function() { 5629 if ( control.invalidDate ) { 5630 control.notifications.add( new api.Notification( 'invalid_date', { 5631 message: api.l10n.invalidDate 5632 } ) ); 5633 } 5634 } ); 5635 5636 // Remove the error immediately after validity change. 5637 input.on( 'input', _.debounce( function() { 5638 if ( ! control.invalidDate ) { 5639 control.notifications.remove( 'invalid_date' ); 5640 } 5641 } ) ); 5642 5643 // Add zero-padding when blurring field. 5644 input.on( 'blur', _.debounce( function() { 5645 if ( ! control.invalidDate ) { 5646 control.populateDateInputs(); 5647 } 5648 } ) ); 5649 } ); 5650 5651 control.inputElements.month.bind( control.updateDaysForMonth ); 5652 control.inputElements.year.bind( control.updateDaysForMonth ); 5653 control.populateDateInputs(); 5654 control.setting.bind( control.populateDateInputs ); 5655 5656 // Start populating setting after inputs have been populated. 5657 _.each( control.inputElements, function( element ) { 5658 element.bind( control.populateSetting ); 5659 } ); 5660 }, 5661 5662 /** 5663 * Parse datetime string. 5664 * 5665 * @since 4.9.0 5666 * 5667 * @param {string} datetime - Date/Time string. Accepts Y-m-d[ H:i[:s]] format. 5668 * @return {Object|null} Returns object containing date components or null if parse error. 5669 */ 5670 parseDateTime: function parseDateTime( datetime ) { 5671 var control = this, matches, date, midDayHour = 12; 5672 5673 if ( datetime ) { 5674 matches = datetime.match( /^(\d\d\d\d)-(\d\d)-(\d\d)(?: (\d\d):(\d\d)(?::(\d\d))?)?$/ ); 5675 } 5676 5677 if ( ! matches ) { 5678 return null; 5679 } 5680 5681 matches.shift(); 5682 5683 date = { 5684 year: matches.shift(), 5685 month: matches.shift(), 5686 day: matches.shift(), 5687 hour: matches.shift() || '00', 5688 minute: matches.shift() || '00', 5689 second: matches.shift() || '00' 5690 }; 5691 5692 if ( control.params.includeTime && control.params.twelveHourFormat ) { 5693 date.hour = parseInt( date.hour, 10 ); 5694 date.meridian = date.hour >= midDayHour ? 'pm' : 'am'; 5695 date.hour = date.hour % midDayHour ? String( date.hour % midDayHour ) : String( midDayHour ); 5696 delete date.second; // @todo Why only if twelveHourFormat? 5697 } 5698 5699 return date; 5700 }, 5701 5702 /** 5703 * Validates if input components have valid date and time. 5704 * 5705 * @since 4.9.0 5706 * @return {boolean} If date input fields has error. 5707 */ 5708 validateInputs: function validateInputs() { 5709 var control = this, components, validityInput; 5710 5711 control.invalidDate = false; 5712 5713 components = [ 'year', 'day' ]; 5714 if ( control.params.includeTime ) { 5715 components.push( 'hour', 'minute' ); 5716 } 5717 5718 _.find( components, function( component ) { 5719 var element, max, min, value; 5720 5721 element = control.inputElements[ component ]; 5722 validityInput = element.element.get( 0 ); 5723 max = parseInt( element.element.attr( 'max' ), 10 ); 5724 min = parseInt( element.element.attr( 'min' ), 10 ); 5725 value = parseInt( element(), 10 ); 5726 control.invalidDate = isNaN( value ) || value > max || value < min; 5727 5728 if ( ! control.invalidDate ) { 5729 validityInput.setCustomValidity( '' ); 5730 } 5731 5732 return control.invalidDate; 5733 } ); 5734 5735 if ( control.inputElements.meridian && ! control.invalidDate ) { 5736 validityInput = control.inputElements.meridian.element.get( 0 ); 5737 if ( 'am' !== control.inputElements.meridian.get() && 'pm' !== control.inputElements.meridian.get() ) { 5738 control.invalidDate = true; 5739 } else { 5740 validityInput.setCustomValidity( '' ); 5741 } 5742 } 5743 5744 if ( control.invalidDate ) { 5745 validityInput.setCustomValidity( api.l10n.invalidValue ); 5746 } else { 5747 validityInput.setCustomValidity( '' ); 5748 } 5749 if ( ! control.section() || api.section.has( control.section() ) && api.section( control.section() ).expanded() ) { 5750 _.result( validityInput, 'reportValidity' ); 5751 } 5752 5753 return control.invalidDate; 5754 }, 5755 5756 /** 5757 * Updates number of days according to the month and year selected. 5758 * 5759 * @since 4.9.0 5760 * @return {void} 5761 */ 5762 updateDaysForMonth: function updateDaysForMonth() { 5763 var control = this, daysInMonth, year, month, day; 5764 5765 month = parseInt( control.inputElements.month(), 10 ); 5766 year = parseInt( control.inputElements.year(), 10 ); 5767 day = parseInt( control.inputElements.day(), 10 ); 5768 5769 if ( month && year ) { 5770 daysInMonth = new Date( year, month, 0 ).getDate(); 5771 control.inputElements.day.element.attr( 'max', daysInMonth ); 5772 5773 if ( day > daysInMonth ) { 5774 control.inputElements.day( String( daysInMonth ) ); 5775 } 5776 } 5777 }, 5778 5779 /** 5780 * Populate setting value from the inputs. 5781 * 5782 * @since 4.9.0 5783 * @return {boolean} If setting updated. 5784 */ 5785 populateSetting: function populateSetting() { 5786 var control = this, date; 5787 5788 if ( control.validateInputs() || ! control.params.allowPastDate && ! control.isFutureDate() ) { 5789 return false; 5790 } 5791 5792 date = control.convertInputDateToString(); 5793 control.setting.set( date ); 5794 return true; 5795 }, 5796 5797 /** 5798 * Converts input values to string in Y-m-d H:i:s format. 5799 * 5800 * @since 4.9.0 5801 * @return {string} Date string. 5802 */ 5803 convertInputDateToString: function convertInputDateToString() { 5804 var control = this, date = '', dateFormat, hourInTwentyFourHourFormat, 5805 getElementValue, pad; 5806 5807 pad = function( number, padding ) { 5808 var zeros; 5809 if ( String( number ).length < padding ) { 5810 zeros = padding - String( number ).length; 5811 number = Math.pow( 10, zeros ).toString().substr( 1 ) + String( number ); 5812 } 5813 return number; 5814 }; 5815 5816 getElementValue = function( component ) { 5817 var value = parseInt( control.inputElements[ component ].get(), 10 ); 5818 5819 if ( _.contains( [ 'month', 'day', 'hour', 'minute' ], component ) ) { 5820 value = pad( value, 2 ); 5821 } else if ( 'year' === component ) { 5822 value = pad( value, 4 ); 5823 } 5824 return value; 5825 }; 5826 5827 dateFormat = [ 'year', '-', 'month', '-', 'day' ]; 5828 if ( control.params.includeTime ) { 5829 hourInTwentyFourHourFormat = control.inputElements.meridian ? control.convertHourToTwentyFourHourFormat( control.inputElements.hour(), control.inputElements.meridian() ) : control.inputElements.hour(); 5830 dateFormat = dateFormat.concat( [ ' ', pad( hourInTwentyFourHourFormat, 2 ), ':', 'minute', ':', '00' ] ); 5831 } 5832 5833 _.each( dateFormat, function( component ) { 5834 date += control.inputElements[ component ] ? getElementValue( component ) : component; 5835 } ); 5836 5837 return date; 5838 }, 5839 5840 /** 5841 * Check if the date is in the future. 5842 * 5843 * @since 4.9.0 5844 * @return {boolean} True if future date. 5845 */ 5846 isFutureDate: function isFutureDate() { 5847 var control = this; 5848 return 0 < api.utils.getRemainingTime( control.convertInputDateToString() ); 5849 }, 5850 5851 /** 5852 * Convert hour in twelve hour format to twenty four hour format. 5853 * 5854 * @since 4.9.0 5855 * @param {string} hourInTwelveHourFormat - Hour in twelve hour format. 5856 * @param {string} meridian - Either 'am' or 'pm'. 5857 * @return {string} Hour in twenty four hour format. 5858 */ 5859 convertHourToTwentyFourHourFormat: function convertHour( hourInTwelveHourFormat, meridian ) { 5860 var hourInTwentyFourHourFormat, hour, midDayHour = 12; 5861 5862 hour = parseInt( hourInTwelveHourFormat, 10 ); 5863 if ( isNaN( hour ) ) { 5864 return ''; 5865 } 5866 5867 if ( 'pm' === meridian && hour < midDayHour ) { 5868 hourInTwentyFourHourFormat = hour + midDayHour; 5869 } else if ( 'am' === meridian && midDayHour === hour ) { 5870 hourInTwentyFourHourFormat = hour - midDayHour; 5871 } else { 5872 hourInTwentyFourHourFormat = hour; 5873 } 5874 5875 return String( hourInTwentyFourHourFormat ); 5876 }, 5877 5878 /** 5879 * Populates date inputs in date fields. 5880 * 5881 * @since 4.9.0 5882 * @return {boolean} Whether the inputs were populated. 5883 */ 5884 populateDateInputs: function populateDateInputs() { 5885 var control = this, parsed; 5886 5887 parsed = control.parseDateTime( control.setting.get() ); 5888 5889 if ( ! parsed ) { 5890 return false; 5891 } 5892 5893 _.each( control.inputElements, function( element, component ) { 5894 var value = parsed[ component ]; // This will be zero-padded string. 5895 5896 // Set month and meridian regardless of focused state since they are dropdowns. 5897 if ( 'month' === component || 'meridian' === component ) { 5898 5899 // Options in dropdowns are not zero-padded. 5900 value = value.replace( /^0/, '' ); 5901 5902 element.set( value ); 5903 } else { 5904 5905 value = parseInt( value, 10 ); 5906 if ( ! element.element.is( document.activeElement ) ) { 5907 5908 // Populate element with zero-padded value if not focused. 5909 element.set( parsed[ component ] ); 5910 } else if ( value !== parseInt( element(), 10 ) ) { 5911 5912 // Forcibly update the value if its underlying value changed, regardless of zero-padding. 5913 element.set( String( value ) ); 5914 } 5915 } 5916 } ); 5917 5918 return true; 5919 }, 5920 5921 /** 5922 * Toggle future date notification for date control. 5923 * 5924 * @since 4.9.0 5925 * @param {boolean} notify Add or remove the notification. 5926 * @return {wp.customize.DateTimeControl} 5927 */ 5928 toggleFutureDateNotification: function toggleFutureDateNotification( notify ) { 5929 var control = this, notificationCode, notification; 5930 5931 notificationCode = 'not_future_date'; 5932 5933 if ( notify ) { 5934 notification = new api.Notification( notificationCode, { 5935 type: 'error', 5936 message: api.l10n.futureDateError 5937 } ); 5938 control.notifications.add( notification ); 5939 } else { 5940 control.notifications.remove( notificationCode ); 5941 } 5942 5943 return control; 5944 } 5945 }); 5946 5947 /** 5948 * Class PreviewLinkControl. 5949 * 5950 * @since 4.9.0 5951 * @class wp.customize.PreviewLinkControl 5952 * @augments wp.customize.Control 5953 */ 5954 api.PreviewLinkControl = api.Control.extend(/** @lends wp.customize.PreviewLinkControl.prototype */{ 5955 5956 defaults: _.extend( {}, api.Control.prototype.defaults, { 5957 templateId: 'customize-preview-link-control' 5958 } ), 5959 5960 /** 5961 * Initialize behaviors. 5962 * 5963 * @since 4.9.0 5964 * @return {void} 5965 */ 5966 ready: function ready() { 5967 var control = this, element, component, node, url, input, button; 5968 5969 _.bindAll( control, 'updatePreviewLink' ); 5970 5971 if ( ! control.setting ) { 5972 control.setting = new api.Value(); 5973 } 5974 5975 control.previewElements = {}; 5976 5977 control.container.find( '.preview-control-element' ).each( function() { 5978 node = $( this ); 5979 component = node.data( 'component' ); 5980 element = new api.Element( node ); 5981 control.previewElements[ component ] = element; 5982 control.elements.push( element ); 5983 } ); 5984 5985 url = control.previewElements.url; 5986 input = control.previewElements.input; 5987 button = control.previewElements.button; 5988 5989 input.link( control.setting ); 5990 url.link( control.setting ); 5991 5992 url.bind( function( value ) { 5993 url.element.parent().attr( { 5994 href: value, 5995 target: api.settings.changeset.uuid 5996 } ); 5997 } ); 5998 5999 api.bind( 'ready', control.updatePreviewLink ); 6000 api.state( 'saved' ).bind( control.updatePreviewLink ); 6001 api.state( 'changesetStatus' ).bind( control.updatePreviewLink ); 6002 api.state( 'activated' ).bind( control.updatePreviewLink ); 6003 api.previewer.previewUrl.bind( control.updatePreviewLink ); 6004 6005 button.element.on( 'click', function( event ) { 6006 event.preventDefault(); 6007 if ( control.setting() ) { 6008 input.element.select(); 6009 document.execCommand( 'copy' ); 6010 button( button.element.data( 'copied-text' ) ); 6011 } 6012 } ); 6013 6014 url.element.parent().on( 'click', function( event ) { 6015 if ( $( this ).hasClass( 'disabled' ) ) { 6016 event.preventDefault(); 6017 } 6018 } ); 6019 6020 button.element.on( 'mouseenter', function() { 6021 if ( control.setting() ) { 6022 button( button.element.data( 'copy-text' ) ); 6023 } 6024 } ); 6025 }, 6026 6027 /** 6028 * Updates Preview Link 6029 * 6030 * @since 4.9.0 6031 * @return {void} 6032 */ 6033 updatePreviewLink: function updatePreviewLink() { 6034 var control = this, unsavedDirtyValues; 6035 6036 unsavedDirtyValues = ! api.state( 'saved' ).get() || '' === api.state( 'changesetStatus' ).get() || 'auto-draft' === api.state( 'changesetStatus' ).get(); 6037 6038 control.toggleSaveNotification( unsavedDirtyValues ); 6039 control.previewElements.url.element.parent().toggleClass( 'disabled', unsavedDirtyValues ); 6040 control.previewElements.button.element.prop( 'disabled', unsavedDirtyValues ); 6041 control.setting.set( api.previewer.getFrontendPreviewUrl() ); 6042 }, 6043 6044 /** 6045 * Toggles save notification. 6046 * 6047 * @since 4.9.0 6048 * @param {boolean} notify Add or remove notification. 6049 * @return {void} 6050 */ 6051 toggleSaveNotification: function toggleSaveNotification( notify ) { 6052 var control = this, notificationCode, notification; 6053 6054 notificationCode = 'changes_not_saved'; 6055 6056 if ( notify ) { 6057 notification = new api.Notification( notificationCode, { 6058 type: 'info', 6059 message: api.l10n.saveBeforeShare 6060 } ); 6061 control.notifications.add( notification ); 6062 } else { 6063 control.notifications.remove( notificationCode ); 6064 } 6065 } 6066 }); 6067 6068 /** 6069 * Change objects contained within the main customize object to Settings. 6070 * 6071 * @alias wp.customize.defaultConstructor 6072 */ 6073 api.defaultConstructor = api.Setting; 6074 6075 /** 6076 * Callback for resolved controls. 6077 * 6078 * @callback wp.customize.deferredControlsCallback 6079 * @param {wp.customize.Control[]} controls Resolved controls. 6080 */ 6081 6082 /** 6083 * Collection of all registered controls. 6084 * 6085 * @alias wp.customize.control 6086 * 6087 * @since 3.4.0 6088 * 6089 * @type {Function} 6090 * @param {...string} ids - One or more ids for controls to obtain. 6091 * @param {deferredControlsCallback} [callback] - Function called when all supplied controls exist. 6092 * @return {wp.customize.Control|undefined|jQuery.promise} Control instance or undefined (if function called with one id param), 6093 * or promise resolving to requested controls. 6094 * 6095 * @example <caption>Loop over all registered controls.</caption> 6096 * wp.customize.control.each( function( control ) { ... } ); 6097 * 6098 * @example <caption>Getting `background_color` control instance.</caption> 6099 * control = wp.customize.control( 'background_color' ); 6100 * 6101 * @example <caption>Check if control exists.</caption> 6102 * hasControl = wp.customize.control.has( 'background_color' ); 6103 * 6104 * @example <caption>Deferred getting of `background_color` control until it exists, using callback.</caption> 6105 * wp.customize.control( 'background_color', function( control ) { ... } ); 6106 * 6107 * @example <caption>Get title and tagline controls when they both exist, using promise (only available when multiple IDs are present).</caption> 6108 * promise = wp.customize.control( 'blogname', 'blogdescription' ); 6109 * promise.done( function( titleControl, taglineControl ) { ... } ); 6110 * 6111 * @example <caption>Get title and tagline controls when they both exist, using callback.</caption> 6112 * wp.customize.control( 'blogname', 'blogdescription', function( titleControl, taglineControl ) { ... } ); 6113 * 6114 * @example <caption>Getting setting value for `background_color` control.</caption> 6115 * value = wp.customize.control( 'background_color ').setting.get(); 6116 * value = wp.customize( 'background_color' ).get(); // Same as above, since setting ID and control ID are the same. 6117 * 6118 * @example <caption>Add new control for site title.</caption> 6119 * wp.customize.control.add( new wp.customize.Control( 'other_blogname', { 6120 * setting: 'blogname', 6121 * type: 'text', 6122 * label: 'Site title', 6123 * section: 'other_site_identify' 6124 * } ) ); 6125 * 6126 * @example <caption>Remove control.</caption> 6127 * wp.customize.control.remove( 'other_blogname' ); 6128 * 6129 * @example <caption>Listen for control being added.</caption> 6130 * wp.customize.control.bind( 'add', function( addedControl ) { ... } ) 6131 * 6132 * @example <caption>Listen for control being removed.</caption> 6133 * wp.customize.control.bind( 'removed', function( removedControl ) { ... } ) 6134 */ 6135 api.control = new api.Values({ defaultConstructor: api.Control }); 6136 6137 /** 6138 * Callback for resolved sections. 6139 * 6140 * @callback wp.customize.deferredSectionsCallback 6141 * @param {wp.customize.Section[]} sections Resolved sections. 6142 */ 6143 6144 /** 6145 * Collection of all registered sections. 6146 * 6147 * @alias wp.customize.section 6148 * 6149 * @since 3.4.0 6150 * 6151 * @type {Function} 6152 * @param {...string} ids - One or more ids for sections to obtain. 6153 * @param {deferredSectionsCallback} [callback] - Function called when all supplied sections exist. 6154 * @return {wp.customize.Section|undefined|jQuery.promise} Section instance or undefined (if function called with one id param), 6155 * or promise resolving to requested sections. 6156 * 6157 * @example <caption>Loop over all registered sections.</caption> 6158 * wp.customize.section.each( function( section ) { ... } ) 6159 * 6160 * @example <caption>Getting `title_tagline` section instance.</caption> 6161 * section = wp.customize.section( 'title_tagline' ) 6162 * 6163 * @example <caption>Expand dynamically-created section when it exists.</caption> 6164 * wp.customize.section( 'dynamically_created', function( section ) { 6165 * section.expand(); 6166 * } ); 6167 * 6168 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6169 */ 6170 api.section = new api.Values({ defaultConstructor: api.Section }); 6171 6172 /** 6173 * Callback for resolved panels. 6174 * 6175 * @callback wp.customize.deferredPanelsCallback 6176 * @param {wp.customize.Panel[]} panels Resolved panels. 6177 */ 6178 6179 /** 6180 * Collection of all registered panels. 6181 * 6182 * @alias wp.customize.panel 6183 * 6184 * @since 4.0.0 6185 * 6186 * @type {Function} 6187 * @param {...string} ids - One or more ids for panels to obtain. 6188 * @param {deferredPanelsCallback} [callback] - Function called when all supplied panels exist. 6189 * @return {wp.customize.Panel|undefined|jQuery.promise} Panel instance or undefined (if function called with one id param), 6190 * or promise resolving to requested panels. 6191 * 6192 * @example <caption>Loop over all registered panels.</caption> 6193 * wp.customize.panel.each( function( panel ) { ... } ) 6194 * 6195 * @example <caption>Getting nav_menus panel instance.</caption> 6196 * panel = wp.customize.panel( 'nav_menus' ); 6197 * 6198 * @example <caption>Expand dynamically-created panel when it exists.</caption> 6199 * wp.customize.panel( 'dynamically_created', function( panel ) { 6200 * panel.expand(); 6201 * } ); 6202 * 6203 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6204 */ 6205 api.panel = new api.Values({ defaultConstructor: api.Panel }); 6206 6207 /** 6208 * Callback for resolved notifications. 6209 * 6210 * @callback wp.customize.deferredNotificationsCallback 6211 * @param {wp.customize.Notification[]} notifications Resolved notifications. 6212 */ 6213 6214 /** 6215 * Collection of all global notifications. 6216 * 6217 * @alias wp.customize.notifications 6218 * 6219 * @since 4.9.0 6220 * 6221 * @type {Function} 6222 * @param {...string} codes - One or more codes for notifications to obtain. 6223 * @param {deferredNotificationsCallback} [callback] - Function called when all supplied notifications exist. 6224 * @return {wp.customize.Notification|undefined|jQuery.promise} Notification instance or undefined (if function called with one code param), 6225 * or promise resolving to requested notifications. 6226 * 6227 * @example <caption>Check if existing notification</caption> 6228 * exists = wp.customize.notifications.has( 'a_new_day_arrived' ); 6229 * 6230 * @example <caption>Obtain existing notification</caption> 6231 * notification = wp.customize.notifications( 'a_new_day_arrived' ); 6232 * 6233 * @example <caption>Obtain notification that may not exist yet.</caption> 6234 * wp.customize.notifications( 'a_new_day_arrived', function( notification ) { ... } ); 6235 * 6236 * @example <caption>Add a warning notification.</caption> 6237 * wp.customize.notifications.add( new wp.customize.Notification( 'midnight_almost_here', { 6238 * type: 'warning', 6239 * message: 'Midnight has almost arrived!', 6240 * dismissible: true 6241 * } ) ); 6242 * 6243 * @example <caption>Remove a notification.</caption> 6244 * wp.customize.notifications.remove( 'a_new_day_arrived' ); 6245 * 6246 * @see {@link wp.customize.control} for further examples of how to interact with {@link wp.customize.Values} instances. 6247 */ 6248 api.notifications = new api.Notifications(); 6249 6250 api.PreviewFrame = api.Messenger.extend(/** @lends wp.customize.PreviewFrame.prototype */{ 6251 sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity. 6252 6253 /** 6254 * An object that fetches a preview in the background of the document, which 6255 * allows for seamless replacement of an existing preview. 6256 * 6257 * @constructs wp.customize.PreviewFrame 6258 * @augments wp.customize.Messenger 6259 * 6260 * @param {Object} params.container 6261 * @param {Object} params.previewUrl 6262 * @param {Object} params.query 6263 * @param {Object} options 6264 */ 6265 initialize: function( params, options ) { 6266 var deferred = $.Deferred(); 6267 6268 /* 6269 * Make the instance of the PreviewFrame the promise object 6270 * so other objects can easily interact with it. 6271 */ 6272 deferred.promise( this ); 6273 6274 this.container = params.container; 6275 6276 $.extend( params, { channel: api.PreviewFrame.uuid() }); 6277 6278 api.Messenger.prototype.initialize.call( this, params, options ); 6279 6280 this.add( 'previewUrl', params.previewUrl ); 6281 6282 this.query = $.extend( params.query || {}, { customize_messenger_channel: this.channel() }); 6283 6284 this.run( deferred ); 6285 }, 6286 6287 /** 6288 * Run the preview request. 6289 * 6290 * @param {Object} deferred jQuery Deferred object to be resolved with 6291 * the request. 6292 */ 6293 run: function( deferred ) { 6294 var previewFrame = this, 6295 loaded = false, 6296 ready = false, 6297 readyData = null, 6298 hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized, 6299 urlParser, 6300 params, 6301 form; 6302 6303 if ( previewFrame._ready ) { 6304 previewFrame.unbind( 'ready', previewFrame._ready ); 6305 } 6306 6307 previewFrame._ready = function( data ) { 6308 ready = true; 6309 readyData = data; 6310 previewFrame.container.addClass( 'iframe-ready' ); 6311 if ( ! data ) { 6312 return; 6313 } 6314 6315 if ( loaded ) { 6316 deferred.resolveWith( previewFrame, [ data ] ); 6317 } 6318 }; 6319 6320 previewFrame.bind( 'ready', previewFrame._ready ); 6321 6322 urlParser = document.createElement( 'a' ); 6323 urlParser.href = previewFrame.previewUrl(); 6324 6325 params = _.extend( 6326 api.utils.parseQueryString( urlParser.search.substr( 1 ) ), 6327 { 6328 customize_changeset_uuid: previewFrame.query.customize_changeset_uuid, 6329 customize_theme: previewFrame.query.customize_theme, 6330 customize_messenger_channel: previewFrame.query.customize_messenger_channel 6331 } 6332 ); 6333 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { 6334 params.customize_autosaved = 'on'; 6335 } 6336 6337 urlParser.search = $.param( params ); 6338 previewFrame.iframe = $( '<iframe />', { 6339 title: api.l10n.previewIframeTitle, 6340 name: 'customize-' + previewFrame.channel() 6341 } ); 6342 previewFrame.iframe.attr( 'onmousewheel', '' ); // Workaround for Safari bug. See WP Trac #38149. 6343 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' ); 6344 6345 if ( ! hasPendingChangesetUpdate ) { 6346 previewFrame.iframe.attr( 'src', urlParser.href ); 6347 } else { 6348 previewFrame.iframe.attr( 'data-src', urlParser.href ); // For debugging purposes. 6349 } 6350 6351 previewFrame.iframe.appendTo( previewFrame.container ); 6352 previewFrame.targetWindow( previewFrame.iframe[0].contentWindow ); 6353 6354 /* 6355 * Submit customized data in POST request to preview frame window since 6356 * there are setting value changes not yet written to changeset. 6357 */ 6358 if ( hasPendingChangesetUpdate ) { 6359 form = $( '<form>', { 6360 action: urlParser.href, 6361 target: previewFrame.iframe.attr( 'name' ), 6362 method: 'post', 6363 hidden: 'hidden' 6364 } ); 6365 form.append( $( '<input>', { 6366 type: 'hidden', 6367 name: '_method', 6368 value: 'GET' 6369 } ) ); 6370 _.each( previewFrame.query, function( value, key ) { 6371 form.append( $( '<input>', { 6372 type: 'hidden', 6373 name: key, 6374 value: value 6375 } ) ); 6376 } ); 6377 previewFrame.container.append( form ); 6378 form.trigger( 'submit' ); 6379 form.remove(); // No need to keep the form around after submitted. 6380 } 6381 6382 previewFrame.bind( 'iframe-loading-error', function( error ) { 6383 previewFrame.iframe.remove(); 6384 6385 // Check if the user is not logged in. 6386 if ( 0 === error ) { 6387 previewFrame.login( deferred ); 6388 return; 6389 } 6390 6391 // Check for cheaters. 6392 if ( -1 === error ) { 6393 deferred.rejectWith( previewFrame, [ 'cheatin' ] ); 6394 return; 6395 } 6396 6397 deferred.rejectWith( previewFrame, [ 'request failure' ] ); 6398 } ); 6399 6400 previewFrame.iframe.one( 'load', function() { 6401 loaded = true; 6402 6403 if ( ready ) { 6404 deferred.resolveWith( previewFrame, [ readyData ] ); 6405 } else { 6406 setTimeout( function() { 6407 deferred.rejectWith( previewFrame, [ 'ready timeout' ] ); 6408 }, previewFrame.sensitivity ); 6409 } 6410 }); 6411 }, 6412 6413 login: function( deferred ) { 6414 var self = this, 6415 reject; 6416 6417 reject = function() { 6418 deferred.rejectWith( self, [ 'logged out' ] ); 6419 }; 6420 6421 if ( this.triedLogin ) { 6422 return reject(); 6423 } 6424 6425 // Check if we have an admin cookie. 6426 $.get( api.settings.url.ajax, { 6427 action: 'logged-in' 6428 }).fail( reject ).done( function( response ) { 6429 var iframe; 6430 6431 if ( '1' !== response ) { 6432 reject(); 6433 } 6434 6435 iframe = $( '<iframe />', { 'src': self.previewUrl(), 'title': api.l10n.previewIframeTitle } ).hide(); 6436 iframe.appendTo( self.container ); 6437 iframe.on( 'load', function() { 6438 self.triedLogin = true; 6439 6440 iframe.remove(); 6441 self.run( deferred ); 6442 }); 6443 }); 6444 }, 6445 6446 destroy: function() { 6447 api.Messenger.prototype.destroy.call( this ); 6448 6449 if ( this.iframe ) { 6450 this.iframe.remove(); 6451 } 6452 6453 delete this.iframe; 6454 delete this.targetWindow; 6455 } 6456 }); 6457 6458 (function(){ 6459 var id = 0; 6460 /** 6461 * Return an incremented ID for a preview messenger channel. 6462 * 6463 * This function is named "uuid" for historical reasons, but it is a 6464 * misnomer as it is not an actual UUID, and it is not universally unique. 6465 * This is not to be confused with `api.settings.changeset.uuid`. 6466 * 6467 * @return {string} 6468 */ 6469 api.PreviewFrame.uuid = function() { 6470 return 'preview-' + String( id++ ); 6471 }; 6472 }()); 6473 6474 /** 6475 * Set the document title of the customizer. 6476 * 6477 * @alias wp.customize.setDocumentTitle 6478 * 6479 * @since 4.1.0 6480 * 6481 * @param {string} documentTitle 6482 */ 6483 api.setDocumentTitle = function ( documentTitle ) { 6484 var tmpl, title; 6485 tmpl = api.settings.documentTitleTmpl; 6486 title = tmpl.replace( '%s', documentTitle ); 6487 document.title = title; 6488 api.trigger( 'title', title ); 6489 }; 6490 6491 api.Previewer = api.Messenger.extend(/** @lends wp.customize.Previewer.prototype */{ 6492 refreshBuffer: null, // Will get set to api.settings.timeouts.windowRefresh. 6493 6494 /** 6495 * @constructs wp.customize.Previewer 6496 * @augments wp.customize.Messenger 6497 * 6498 * @param {Array} params.allowedUrls 6499 * @param {string} params.container A selector or jQuery element for the preview 6500 * frame to be placed. 6501 * @param {string} params.form 6502 * @param {string} params.previewUrl The URL to preview. 6503 * @param {Object} options 6504 */ 6505 initialize: function( params, options ) { 6506 var previewer = this, 6507 urlParser = document.createElement( 'a' ); 6508 6509 $.extend( previewer, options || {} ); 6510 previewer.deferred = { 6511 active: $.Deferred() 6512 }; 6513 6514 // Debounce to prevent hammering server and then wait for any pending update requests. 6515 previewer.refresh = _.debounce( 6516 ( function( originalRefresh ) { 6517 return function() { 6518 var isProcessingComplete, refreshOnceProcessingComplete; 6519 isProcessingComplete = function() { 6520 return 0 === api.state( 'processing' ).get(); 6521 }; 6522 if ( isProcessingComplete() ) { 6523 originalRefresh.call( previewer ); 6524 } else { 6525 refreshOnceProcessingComplete = function() { 6526 if ( isProcessingComplete() ) { 6527 originalRefresh.call( previewer ); 6528 api.state( 'processing' ).unbind( refreshOnceProcessingComplete ); 6529 } 6530 }; 6531 api.state( 'processing' ).bind( refreshOnceProcessingComplete ); 6532 } 6533 }; 6534 }( previewer.refresh ) ), 6535 previewer.refreshBuffer 6536 ); 6537 6538 previewer.container = api.ensure( params.container ); 6539 previewer.allowedUrls = params.allowedUrls; 6540 6541 params.url = window.location.href; 6542 6543 api.Messenger.prototype.initialize.call( previewer, params ); 6544 6545 urlParser.href = previewer.origin(); 6546 previewer.add( 'scheme', urlParser.protocol.replace( /:$/, '' ) ); 6547 6548 /* 6549 * Limit the URL to internal, front-end links. 6550 * 6551 * If the front end and the admin are served from the same domain, load the 6552 * preview over ssl if the Customizer is being loaded over ssl. This avoids 6553 * insecure content warnings. This is not attempted if the admin and front end 6554 * are on different domains to avoid the case where the front end doesn't have 6555 * ssl certs. 6556 */ 6557 6558 previewer.add( 'previewUrl', params.previewUrl ).setter( function( to ) { 6559 var result = null, urlParser, queryParams, parsedAllowedUrl, parsedCandidateUrls = []; 6560 urlParser = document.createElement( 'a' ); 6561 urlParser.href = to; 6562 6563 // Abort if URL is for admin or (static) files in wp-includes or wp-content. 6564 if ( /\/wp-(admin|includes|content)(\/|$)/.test( urlParser.pathname ) ) { 6565 return null; 6566 } 6567 6568 // Remove state query params. 6569 if ( urlParser.search.length > 1 ) { 6570 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 6571 delete queryParams.customize_changeset_uuid; 6572 delete queryParams.customize_theme; 6573 delete queryParams.customize_messenger_channel; 6574 delete queryParams.customize_autosaved; 6575 if ( _.isEmpty( queryParams ) ) { 6576 urlParser.search = ''; 6577 } else { 6578 urlParser.search = $.param( queryParams ); 6579 } 6580 } 6581 6582 parsedCandidateUrls.push( urlParser ); 6583 6584 // Prepend list with URL that matches the scheme/protocol of the iframe. 6585 if ( previewer.scheme.get() + ':' !== urlParser.protocol ) { 6586 urlParser = document.createElement( 'a' ); 6587 urlParser.href = parsedCandidateUrls[0].href; 6588 urlParser.protocol = previewer.scheme.get() + ':'; 6589 parsedCandidateUrls.unshift( urlParser ); 6590 } 6591 6592 // Attempt to match the URL to the control frame's scheme and check if it's allowed. If not, try the original URL. 6593 parsedAllowedUrl = document.createElement( 'a' ); 6594 _.find( parsedCandidateUrls, function( parsedCandidateUrl ) { 6595 return ! _.isUndefined( _.find( previewer.allowedUrls, function( allowedUrl ) { 6596 parsedAllowedUrl.href = allowedUrl; 6597 if ( urlParser.protocol === parsedAllowedUrl.protocol && urlParser.host === parsedAllowedUrl.host && 0 === urlParser.pathname.indexOf( parsedAllowedUrl.pathname.replace( /\/$/, '' ) ) ) { 6598 result = parsedCandidateUrl.href; 6599 return true; 6600 } 6601 } ) ); 6602 } ); 6603 6604 return result; 6605 }); 6606 6607 previewer.bind( 'ready', previewer.ready ); 6608 6609 // Start listening for keep-alive messages when iframe first loads. 6610 previewer.deferred.active.done( _.bind( previewer.keepPreviewAlive, previewer ) ); 6611 6612 previewer.bind( 'synced', function() { 6613 previewer.send( 'active' ); 6614 } ); 6615 6616 // Refresh the preview when the URL is changed (but not yet). 6617 previewer.previewUrl.bind( previewer.refresh ); 6618 6619 previewer.scroll = 0; 6620 previewer.bind( 'scroll', function( distance ) { 6621 previewer.scroll = distance; 6622 }); 6623 6624 // Update the URL when the iframe sends a URL message, resetting scroll position. If URL is unchanged, then refresh. 6625 previewer.bind( 'url', function( url ) { 6626 var onUrlChange, urlChanged = false; 6627 previewer.scroll = 0; 6628 onUrlChange = function() { 6629 urlChanged = true; 6630 }; 6631 previewer.previewUrl.bind( onUrlChange ); 6632 previewer.previewUrl.set( url ); 6633 previewer.previewUrl.unbind( onUrlChange ); 6634 if ( ! urlChanged ) { 6635 previewer.refresh(); 6636 } 6637 } ); 6638 6639 // Update the document title when the preview changes. 6640 previewer.bind( 'documentTitle', function ( title ) { 6641 api.setDocumentTitle( title ); 6642 } ); 6643 }, 6644 6645 /** 6646 * Handle the preview receiving the ready message. 6647 * 6648 * @since 4.7.0 6649 * @access public 6650 * 6651 * @param {Object} data - Data from preview. 6652 * @param {string} data.currentUrl - Current URL. 6653 * @param {Object} data.activePanels - Active panels. 6654 * @param {Object} data.activeSections Active sections. 6655 * @param {Object} data.activeControls Active controls. 6656 * @return {void} 6657 */ 6658 ready: function( data ) { 6659 var previewer = this, synced = {}, constructs; 6660 6661 synced.settings = api.get(); 6662 synced['settings-modified-while-loading'] = previewer.settingsModifiedWhileLoading; 6663 if ( 'resolved' !== previewer.deferred.active.state() || previewer.loading ) { 6664 synced.scroll = previewer.scroll; 6665 } 6666 synced['edit-shortcut-visibility'] = api.state( 'editShortcutVisibility' ).get(); 6667 previewer.send( 'sync', synced ); 6668 6669 // Set the previewUrl without causing the url to set the iframe. 6670 if ( data.currentUrl ) { 6671 previewer.previewUrl.unbind( previewer.refresh ); 6672 previewer.previewUrl.set( data.currentUrl ); 6673 previewer.previewUrl.bind( previewer.refresh ); 6674 } 6675 6676 /* 6677 * Walk over all panels, sections, and controls and set their 6678 * respective active states to true if the preview explicitly 6679 * indicates as such. 6680 */ 6681 constructs = { 6682 panel: data.activePanels, 6683 section: data.activeSections, 6684 control: data.activeControls 6685 }; 6686 _( constructs ).each( function ( activeConstructs, type ) { 6687 api[ type ].each( function ( construct, id ) { 6688 var isDynamicallyCreated = _.isUndefined( api.settings[ type + 's' ][ id ] ); 6689 6690 /* 6691 * If the construct was created statically in PHP (not dynamically in JS) 6692 * then consider a missing (undefined) value in the activeConstructs to 6693 * mean it should be deactivated (since it is gone). But if it is 6694 * dynamically created then only toggle activation if the value is defined, 6695 * as this means that the construct was also then correspondingly 6696 * created statically in PHP and the active callback is available. 6697 * Otherwise, dynamically-created constructs should normally have 6698 * their active states toggled in JS rather than from PHP. 6699 */ 6700 if ( ! isDynamicallyCreated || ! _.isUndefined( activeConstructs[ id ] ) ) { 6701 if ( activeConstructs[ id ] ) { 6702 construct.activate(); 6703 } else { 6704 construct.deactivate(); 6705 } 6706 } 6707 } ); 6708 } ); 6709 6710 if ( data.settingValidities ) { 6711 api._handleSettingValidities( { 6712 settingValidities: data.settingValidities, 6713 focusInvalidControl: false 6714 } ); 6715 } 6716 }, 6717 6718 /** 6719 * Keep the preview alive by listening for ready and keep-alive messages. 6720 * 6721 * If a message is not received in the allotted time then the iframe will be set back to the last known valid URL. 6722 * 6723 * @since 4.7.0 6724 * @access public 6725 * 6726 * @return {void} 6727 */ 6728 keepPreviewAlive: function keepPreviewAlive() { 6729 var previewer = this, keepAliveTick, timeoutId, handleMissingKeepAlive, scheduleKeepAliveCheck; 6730 6731 /** 6732 * Schedule a preview keep-alive check. 6733 * 6734 * Note that if a page load takes longer than keepAliveCheck milliseconds, 6735 * the keep-alive messages will still be getting sent from the previous 6736 * URL. 6737 */ 6738 scheduleKeepAliveCheck = function() { 6739 timeoutId = setTimeout( handleMissingKeepAlive, api.settings.timeouts.keepAliveCheck ); 6740 }; 6741 6742 /** 6743 * Set the previewerAlive state to true when receiving a message from the preview. 6744 */ 6745 keepAliveTick = function() { 6746 api.state( 'previewerAlive' ).set( true ); 6747 clearTimeout( timeoutId ); 6748 scheduleKeepAliveCheck(); 6749 }; 6750 6751 /** 6752 * Set the previewerAlive state to false if keepAliveCheck milliseconds have transpired without a message. 6753 * 6754 * This is most likely to happen in the case of a connectivity error, or if the theme causes the browser 6755 * to navigate to a non-allowed URL. Setting this state to false will force settings with a postMessage 6756 * transport to use refresh instead, causing the preview frame also to be replaced with the current 6757 * allowed preview URL. 6758 */ 6759 handleMissingKeepAlive = function() { 6760 api.state( 'previewerAlive' ).set( false ); 6761 }; 6762 scheduleKeepAliveCheck(); 6763 6764 previewer.bind( 'ready', keepAliveTick ); 6765 previewer.bind( 'keep-alive', keepAliveTick ); 6766 }, 6767 6768 /** 6769 * Query string data sent with each preview request. 6770 * 6771 * @abstract 6772 */ 6773 query: function() {}, 6774 6775 abort: function() { 6776 if ( this.loading ) { 6777 this.loading.destroy(); 6778 delete this.loading; 6779 } 6780 }, 6781 6782 /** 6783 * Refresh the preview seamlessly. 6784 * 6785 * @since 3.4.0 6786 * @access public 6787 * 6788 * @return {void} 6789 */ 6790 refresh: function() { 6791 var previewer = this, onSettingChange; 6792 6793 // Display loading indicator. 6794 previewer.send( 'loading-initiated' ); 6795 6796 previewer.abort(); 6797 6798 previewer.loading = new api.PreviewFrame({ 6799 url: previewer.url(), 6800 previewUrl: previewer.previewUrl(), 6801 query: previewer.query( { excludeCustomizedSaved: true } ) || {}, 6802 container: previewer.container 6803 }); 6804 6805 previewer.settingsModifiedWhileLoading = {}; 6806 onSettingChange = function( setting ) { 6807 previewer.settingsModifiedWhileLoading[ setting.id ] = true; 6808 }; 6809 api.bind( 'change', onSettingChange ); 6810 previewer.loading.always( function() { 6811 api.unbind( 'change', onSettingChange ); 6812 } ); 6813 6814 previewer.loading.done( function( readyData ) { 6815 var loadingFrame = this, onceSynced; 6816 6817 previewer.preview = loadingFrame; 6818 previewer.targetWindow( loadingFrame.targetWindow() ); 6819 previewer.channel( loadingFrame.channel() ); 6820 6821 onceSynced = function() { 6822 loadingFrame.unbind( 'synced', onceSynced ); 6823 if ( previewer._previousPreview ) { 6824 previewer._previousPreview.destroy(); 6825 } 6826 previewer._previousPreview = previewer.preview; 6827 previewer.deferred.active.resolve(); 6828 delete previewer.loading; 6829 }; 6830 loadingFrame.bind( 'synced', onceSynced ); 6831 6832 // This event will be received directly by the previewer in normal navigation; this is only needed for seamless refresh. 6833 previewer.trigger( 'ready', readyData ); 6834 }); 6835 6836 previewer.loading.fail( function( reason ) { 6837 previewer.send( 'loading-failed' ); 6838 6839 if ( 'logged out' === reason ) { 6840 if ( previewer.preview ) { 6841 previewer.preview.destroy(); 6842 delete previewer.preview; 6843 } 6844 6845 previewer.login().done( previewer.refresh ); 6846 } 6847 6848 if ( 'cheatin' === reason ) { 6849 previewer.cheatin(); 6850 } 6851 }); 6852 }, 6853 6854 login: function() { 6855 var previewer = this, 6856 deferred, messenger, iframe; 6857 6858 if ( this._login ) { 6859 return this._login; 6860 } 6861 6862 deferred = $.Deferred(); 6863 this._login = deferred.promise(); 6864 6865 messenger = new api.Messenger({ 6866 channel: 'login', 6867 url: api.settings.url.login 6868 }); 6869 6870 iframe = $( '<iframe />', { 'src': api.settings.url.login, 'title': api.l10n.loginIframeTitle } ).appendTo( this.container ); 6871 6872 messenger.targetWindow( iframe[0].contentWindow ); 6873 6874 messenger.bind( 'login', function () { 6875 var refreshNonces = previewer.refreshNonces(); 6876 6877 refreshNonces.always( function() { 6878 iframe.remove(); 6879 messenger.destroy(); 6880 delete previewer._login; 6881 }); 6882 6883 refreshNonces.done( function() { 6884 deferred.resolve(); 6885 }); 6886 6887 refreshNonces.fail( function() { 6888 previewer.cheatin(); 6889 deferred.reject(); 6890 }); 6891 }); 6892 6893 return this._login; 6894 }, 6895 6896 cheatin: function() { 6897 $( document.body ).empty().addClass( 'cheatin' ).append( 6898 '<h1>' + api.l10n.notAllowedHeading + '</h1>' + 6899 '<p>' + api.l10n.notAllowed + '</p>' 6900 ); 6901 }, 6902 6903 refreshNonces: function() { 6904 var request, deferred = $.Deferred(); 6905 6906 deferred.promise(); 6907 6908 request = wp.ajax.post( 'customize_refresh_nonces', { 6909 wp_customize: 'on', 6910 customize_theme: api.settings.theme.stylesheet 6911 }); 6912 6913 request.done( function( response ) { 6914 api.trigger( 'nonce-refresh', response ); 6915 deferred.resolve(); 6916 }); 6917 6918 request.fail( function() { 6919 deferred.reject(); 6920 }); 6921 6922 return deferred; 6923 } 6924 }); 6925 6926 api.settingConstructor = {}; 6927 api.controlConstructor = { 6928 color: api.ColorControl, 6929 media: api.MediaControl, 6930 upload: api.UploadControl, 6931 image: api.ImageControl, 6932 cropped_image: api.CroppedImageControl, 6933 site_icon: api.SiteIconControl, 6934 header: api.HeaderControl, 6935 background: api.BackgroundControl, 6936 background_position: api.BackgroundPositionControl, 6937 theme: api.ThemeControl, 6938 date_time: api.DateTimeControl, 6939 code_editor: api.CodeEditorControl 6940 }; 6941 api.panelConstructor = { 6942 themes: api.ThemesPanel 6943 }; 6944 api.sectionConstructor = { 6945 themes: api.ThemesSection, 6946 outer: api.OuterSection 6947 }; 6948 6949 /** 6950 * Handle setting_validities in an error response for the customize-save request. 6951 * 6952 * Add notifications to the settings and focus on the first control that has an invalid setting. 6953 * 6954 * @alias wp.customize._handleSettingValidities 6955 * 6956 * @since 4.6.0 6957 * @private 6958 * 6959 * @param {Object} args 6960 * @param {Object} args.settingValidities 6961 * @param {boolean} [args.focusInvalidControl=false] 6962 * @return {void} 6963 */ 6964 api._handleSettingValidities = function handleSettingValidities( args ) { 6965 var invalidSettingControls, invalidSettings = [], wasFocused = false; 6966 6967 // Find the controls that correspond to each invalid setting. 6968 _.each( args.settingValidities, function( validity, settingId ) { 6969 var setting = api( settingId ); 6970 if ( setting ) { 6971 6972 // Add notifications for invalidities. 6973 if ( _.isObject( validity ) ) { 6974 _.each( validity, function( params, code ) { 6975 var notification, existingNotification, needsReplacement = false; 6976 notification = new api.Notification( code, _.extend( { fromServer: true }, params ) ); 6977 6978 // Remove existing notification if already exists for code but differs in parameters. 6979 existingNotification = setting.notifications( notification.code ); 6980 if ( existingNotification ) { 6981 needsReplacement = notification.type !== existingNotification.type || notification.message !== existingNotification.message || ! _.isEqual( notification.data, existingNotification.data ); 6982 } 6983 if ( needsReplacement ) { 6984 setting.notifications.remove( code ); 6985 } 6986 6987 if ( ! setting.notifications.has( notification.code ) ) { 6988 setting.notifications.add( notification ); 6989 } 6990 invalidSettings.push( setting.id ); 6991 } ); 6992 } 6993 6994 // Remove notification errors that are no longer valid. 6995 setting.notifications.each( function( notification ) { 6996 if ( notification.fromServer && 'error' === notification.type && ( true === validity || ! validity[ notification.code ] ) ) { 6997 setting.notifications.remove( notification.code ); 6998 } 6999 } ); 7000 } 7001 } ); 7002 7003 if ( args.focusInvalidControl ) { 7004 invalidSettingControls = api.findControlsForSettings( invalidSettings ); 7005 7006 // Focus on the first control that is inside of an expanded section (one that is visible). 7007 _( _.values( invalidSettingControls ) ).find( function( controls ) { 7008 return _( controls ).find( function( control ) { 7009 var isExpanded = control.section() && api.section.has( control.section() ) && api.section( control.section() ).expanded(); 7010 if ( isExpanded && control.expanded ) { 7011 isExpanded = control.expanded(); 7012 } 7013 if ( isExpanded ) { 7014 control.focus(); 7015 wasFocused = true; 7016 } 7017 return wasFocused; 7018 } ); 7019 } ); 7020 7021 // Focus on the first invalid control. 7022 if ( ! wasFocused && ! _.isEmpty( invalidSettingControls ) ) { 7023 _.values( invalidSettingControls )[0][0].focus(); 7024 } 7025 } 7026 }; 7027 7028 /** 7029 * Find all controls associated with the given settings. 7030 * 7031 * @alias wp.customize.findControlsForSettings 7032 * 7033 * @since 4.6.0 7034 * @param {string[]} settingIds Setting IDs. 7035 * @return {Object<string, wp.customize.Control>} Mapping setting ids to arrays of controls. 7036 */ 7037 api.findControlsForSettings = function findControlsForSettings( settingIds ) { 7038 var controls = {}, settingControls; 7039 _.each( _.unique( settingIds ), function( settingId ) { 7040 var setting = api( settingId ); 7041 if ( setting ) { 7042 settingControls = setting.findControls(); 7043 if ( settingControls && settingControls.length > 0 ) { 7044 controls[ settingId ] = settingControls; 7045 } 7046 } 7047 } ); 7048 return controls; 7049 }; 7050 7051 /** 7052 * Sort panels, sections, controls by priorities. Hide empty sections and panels. 7053 * 7054 * @alias wp.customize.reflowPaneContents 7055 * 7056 * @since 4.1.0 7057 */ 7058 api.reflowPaneContents = _.bind( function () { 7059 7060 var appendContainer, activeElement, rootHeadContainers, rootNodes = [], wasReflowed = false; 7061 7062 if ( document.activeElement ) { 7063 activeElement = $( document.activeElement ); 7064 } 7065 7066 // Sort the sections within each panel. 7067 api.panel.each( function ( panel ) { 7068 if ( 'themes' === panel.id ) { 7069 return; // Don't reflow theme sections, as doing so moves them after the themes container. 7070 } 7071 7072 var sections = panel.sections(), 7073 sectionHeadContainers = _.pluck( sections, 'headContainer' ); 7074 rootNodes.push( panel ); 7075 appendContainer = ( panel.contentContainer.is( 'ul' ) ) ? panel.contentContainer : panel.contentContainer.find( 'ul:first' ); 7076 if ( ! api.utils.areElementListsEqual( sectionHeadContainers, appendContainer.children( '[id]' ) ) ) { 7077 _( sections ).each( function ( section ) { 7078 appendContainer.append( section.headContainer ); 7079 } ); 7080 wasReflowed = true; 7081 } 7082 } ); 7083 7084 // Sort the controls within each section. 7085 api.section.each( function ( section ) { 7086 var controls = section.controls(), 7087 controlContainers = _.pluck( controls, 'container' ); 7088 if ( ! section.panel() ) { 7089 rootNodes.push( section ); 7090 } 7091 appendContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' ); 7092 if ( ! api.utils.areElementListsEqual( controlContainers, appendContainer.children( '[id]' ) ) ) { 7093 _( controls ).each( function ( control ) { 7094 appendContainer.append( control.container ); 7095 } ); 7096 wasReflowed = true; 7097 } 7098 } ); 7099 7100 // Sort the root panels and sections. 7101 rootNodes.sort( api.utils.prioritySort ); 7102 rootHeadContainers = _.pluck( rootNodes, 'headContainer' ); 7103 appendContainer = $( '#customize-theme-controls .customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable. 7104 if ( ! api.utils.areElementListsEqual( rootHeadContainers, appendContainer.children() ) ) { 7105 _( rootNodes ).each( function ( rootNode ) { 7106 appendContainer.append( rootNode.headContainer ); 7107 } ); 7108 wasReflowed = true; 7109 } 7110 7111 // Now re-trigger the active Value callbacks so that the panels and sections can decide whether they can be rendered. 7112 api.panel.each( function ( panel ) { 7113 var value = panel.active(); 7114 panel.active.callbacks.fireWith( panel.active, [ value, value ] ); 7115 } ); 7116 api.section.each( function ( section ) { 7117 var value = section.active(); 7118 section.active.callbacks.fireWith( section.active, [ value, value ] ); 7119 } ); 7120 7121 // Restore focus if there was a reflow and there was an active (focused) element. 7122 if ( wasReflowed && activeElement ) { 7123 activeElement.trigger( 'focus' ); 7124 } 7125 api.trigger( 'pane-contents-reflowed' ); 7126 }, api ); 7127 7128 // Define state values. 7129 api.state = new api.Values(); 7130 _.each( [ 7131 'saved', 7132 'saving', 7133 'trashing', 7134 'activated', 7135 'processing', 7136 'paneVisible', 7137 'expandedPanel', 7138 'expandedSection', 7139 'changesetDate', 7140 'selectedChangesetDate', 7141 'changesetStatus', 7142 'selectedChangesetStatus', 7143 'remainingTimeToPublish', 7144 'previewerAlive', 7145 'editShortcutVisibility', 7146 'changesetLocked', 7147 'previewedDevice' 7148 ], function( name ) { 7149 api.state.create( name ); 7150 }); 7151 7152 $( function() { 7153 api.settings = window._wpCustomizeSettings; 7154 api.l10n = window._wpCustomizeControlsL10n; 7155 7156 // Check if we can run the Customizer. 7157 if ( ! api.settings ) { 7158 return; 7159 } 7160 7161 // Bail if any incompatibilities are found. 7162 if ( ! $.support.postMessage || ( ! $.support.cors && api.settings.isCrossDomain ) ) { 7163 return; 7164 } 7165 7166 if ( null === api.PreviewFrame.prototype.sensitivity ) { 7167 api.PreviewFrame.prototype.sensitivity = api.settings.timeouts.previewFrameSensitivity; 7168 } 7169 if ( null === api.Previewer.prototype.refreshBuffer ) { 7170 api.Previewer.prototype.refreshBuffer = api.settings.timeouts.windowRefresh; 7171 } 7172 7173 var parent, 7174 body = $( document.body ), 7175 overlay = body.children( '.wp-full-overlay' ), 7176 title = $( '#customize-info .panel-title.site-title' ), 7177 closeBtn = $( '.customize-controls-close' ), 7178 saveBtn = $( '#save' ), 7179 btnWrapper = $( '#customize-save-button-wrapper' ), 7180 publishSettingsBtn = $( '#publish-settings' ), 7181 footerActions = $( '#customize-footer-actions' ); 7182 7183 // Add publish settings section in JS instead of PHP since the Customizer depends on it to function. 7184 api.bind( 'ready', function() { 7185 api.section.add( new api.OuterSection( 'publish_settings', { 7186 title: api.l10n.publishSettings, 7187 priority: 0, 7188 active: api.settings.theme.active 7189 } ) ); 7190 } ); 7191 7192 // Set up publish settings section and its controls. 7193 api.section( 'publish_settings', function( section ) { 7194 var updateButtonsState, trashControl, updateSectionActive, isSectionActive, statusControl, dateControl, toggleDateControl, publishWhenTime, pollInterval, updateTimeArrivedPoller, cancelScheduleButtonReminder, timeArrivedPollingInterval = 1000; 7195 7196 trashControl = new api.Control( 'trash_changeset', { 7197 type: 'button', 7198 section: section.id, 7199 priority: 30, 7200 input_attrs: { 7201 'class': 'button-link button-link-delete', 7202 value: api.l10n.discardChanges 7203 } 7204 } ); 7205 api.control.add( trashControl ); 7206 trashControl.deferred.embedded.done( function() { 7207 trashControl.container.find( '.button-link' ).on( 'click', function() { 7208 if ( confirm( api.l10n.trashConfirm ) ) { 7209 wp.customize.previewer.trash(); 7210 } 7211 } ); 7212 } ); 7213 7214 api.control.add( new api.PreviewLinkControl( 'changeset_preview_link', { 7215 section: section.id, 7216 priority: 100 7217 } ) ); 7218 7219 /** 7220 * Return whether the publish settings section should be active. 7221 * 7222 * @return {boolean} Is section active. 7223 */ 7224 isSectionActive = function() { 7225 if ( ! api.state( 'activated' ).get() ) { 7226 return false; 7227 } 7228 if ( api.state( 'trashing' ).get() || 'trash' === api.state( 'changesetStatus' ).get() ) { 7229 return false; 7230 } 7231 if ( '' === api.state( 'changesetStatus' ).get() && api.state( 'saved' ).get() ) { 7232 return false; 7233 } 7234 return true; 7235 }; 7236 7237 // Make sure publish settings are not available while the theme is not active and the customizer is in a published state. 7238 section.active.validate = isSectionActive; 7239 updateSectionActive = function() { 7240 section.active.set( isSectionActive() ); 7241 }; 7242 api.state( 'activated' ).bind( updateSectionActive ); 7243 api.state( 'trashing' ).bind( updateSectionActive ); 7244 api.state( 'saved' ).bind( updateSectionActive ); 7245 api.state( 'changesetStatus' ).bind( updateSectionActive ); 7246 updateSectionActive(); 7247 7248 // Bind visibility of the publish settings button to whether the section is active. 7249 updateButtonsState = function() { 7250 publishSettingsBtn.toggle( section.active.get() ); 7251 saveBtn.toggleClass( 'has-next-sibling', section.active.get() ); 7252 }; 7253 updateButtonsState(); 7254 section.active.bind( updateButtonsState ); 7255 7256 function highlightScheduleButton() { 7257 if ( ! cancelScheduleButtonReminder ) { 7258 cancelScheduleButtonReminder = api.utils.highlightButton( btnWrapper, { 7259 delay: 1000, 7260 7261 /* 7262 * Only abort the reminder when the save button is focused. 7263 * If the user clicks the settings button to toggle the 7264 * settings closed, we'll still remind them. 7265 */ 7266 focusTarget: saveBtn 7267 } ); 7268 } 7269 } 7270 function cancelHighlightScheduleButton() { 7271 if ( cancelScheduleButtonReminder ) { 7272 cancelScheduleButtonReminder(); 7273 cancelScheduleButtonReminder = null; 7274 } 7275 } 7276 api.state( 'selectedChangesetStatus' ).bind( cancelHighlightScheduleButton ); 7277 7278 section.contentContainer.find( '.customize-action' ).text( api.l10n.updating ); 7279 section.contentContainer.find( '.customize-section-back' ).removeAttr( 'tabindex' ); 7280 publishSettingsBtn.prop( 'disabled', false ); 7281 7282 publishSettingsBtn.on( 'click', function( event ) { 7283 event.preventDefault(); 7284 section.expanded.set( ! section.expanded.get() ); 7285 } ); 7286 7287 section.expanded.bind( function( isExpanded ) { 7288 var defaultChangesetStatus; 7289 publishSettingsBtn.attr( 'aria-expanded', String( isExpanded ) ); 7290 publishSettingsBtn.toggleClass( 'active', isExpanded ); 7291 7292 if ( isExpanded ) { 7293 cancelHighlightScheduleButton(); 7294 return; 7295 } 7296 7297 defaultChangesetStatus = api.state( 'changesetStatus' ).get(); 7298 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) { 7299 defaultChangesetStatus = 'publish'; 7300 } 7301 7302 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) { 7303 highlightScheduleButton(); 7304 } else if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) { 7305 highlightScheduleButton(); 7306 } 7307 } ); 7308 7309 statusControl = new api.Control( 'changeset_status', { 7310 priority: 10, 7311 type: 'radio', 7312 section: 'publish_settings', 7313 setting: api.state( 'selectedChangesetStatus' ), 7314 templateId: 'customize-selected-changeset-status-control', 7315 label: api.l10n.action, 7316 choices: api.settings.changeset.statusChoices 7317 } ); 7318 api.control.add( statusControl ); 7319 7320 dateControl = new api.DateTimeControl( 'changeset_scheduled_date', { 7321 priority: 20, 7322 section: 'publish_settings', 7323 setting: api.state( 'selectedChangesetDate' ), 7324 minYear: ( new Date() ).getFullYear(), 7325 allowPastDate: false, 7326 includeTime: true, 7327 twelveHourFormat: /a/i.test( api.settings.timeFormat ), 7328 description: api.l10n.scheduleDescription 7329 } ); 7330 dateControl.notifications.alt = true; 7331 api.control.add( dateControl ); 7332 7333 publishWhenTime = function() { 7334 api.state( 'selectedChangesetStatus' ).set( 'publish' ); 7335 api.previewer.save(); 7336 }; 7337 7338 // Start countdown for when the dateTime arrives, or clear interval when it is . 7339 updateTimeArrivedPoller = function() { 7340 var shouldPoll = ( 7341 'future' === api.state( 'changesetStatus' ).get() && 7342 'future' === api.state( 'selectedChangesetStatus' ).get() && 7343 api.state( 'changesetDate' ).get() && 7344 api.state( 'selectedChangesetDate' ).get() === api.state( 'changesetDate' ).get() && 7345 api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ) >= 0 7346 ); 7347 7348 if ( shouldPoll && ! pollInterval ) { 7349 pollInterval = setInterval( function() { 7350 var remainingTime = api.utils.getRemainingTime( api.state( 'changesetDate' ).get() ); 7351 api.state( 'remainingTimeToPublish' ).set( remainingTime ); 7352 if ( remainingTime <= 0 ) { 7353 clearInterval( pollInterval ); 7354 pollInterval = 0; 7355 publishWhenTime(); 7356 } 7357 }, timeArrivedPollingInterval ); 7358 } else if ( ! shouldPoll && pollInterval ) { 7359 clearInterval( pollInterval ); 7360 pollInterval = 0; 7361 } 7362 }; 7363 7364 api.state( 'changesetDate' ).bind( updateTimeArrivedPoller ); 7365 api.state( 'selectedChangesetDate' ).bind( updateTimeArrivedPoller ); 7366 api.state( 'changesetStatus' ).bind( updateTimeArrivedPoller ); 7367 api.state( 'selectedChangesetStatus' ).bind( updateTimeArrivedPoller ); 7368 updateTimeArrivedPoller(); 7369 7370 // Ensure dateControl only appears when selected status is future. 7371 dateControl.active.validate = function() { 7372 return 'future' === api.state( 'selectedChangesetStatus' ).get(); 7373 }; 7374 toggleDateControl = function( value ) { 7375 dateControl.active.set( 'future' === value ); 7376 }; 7377 toggleDateControl( api.state( 'selectedChangesetStatus' ).get() ); 7378 api.state( 'selectedChangesetStatus' ).bind( toggleDateControl ); 7379 7380 // Show notification on date control when status is future but it isn't a future date. 7381 api.state( 'saving' ).bind( function( isSaving ) { 7382 if ( isSaving && 'future' === api.state( 'selectedChangesetStatus' ).get() ) { 7383 dateControl.toggleFutureDateNotification( ! dateControl.isFutureDate() ); 7384 } 7385 } ); 7386 } ); 7387 7388 // Prevent the form from saving when enter is pressed on an input or select element. 7389 $('#customize-controls').on( 'keydown', function( e ) { 7390 var isEnter = ( 13 === e.which ), 7391 $el = $( e.target ); 7392 7393 if ( isEnter && ( $el.is( 'input:not([type=button])' ) || $el.is( 'select' ) ) ) { 7394 e.preventDefault(); 7395 } 7396 }); 7397 7398 // Expand/Collapse the main customizer customize info. 7399 $( '.customize-info' ).find( '> .accordion-section-title .customize-help-toggle' ).on( 'click', function() { 7400 var section = $( this ).closest( '.accordion-section' ), 7401 content = section.find( '.customize-panel-description:first' ); 7402 7403 if ( section.hasClass( 'cannot-expand' ) ) { 7404 return; 7405 } 7406 7407 if ( section.hasClass( 'open' ) ) { 7408 section.toggleClass( 'open' ); 7409 content.slideUp( api.Panel.prototype.defaultExpandedArguments.duration, function() { 7410 content.trigger( 'toggled' ); 7411 } ); 7412 $( this ).attr( 'aria-expanded', false ); 7413 } else { 7414 content.slideDown( api.Panel.prototype.defaultExpandedArguments.duration, function() { 7415 content.trigger( 'toggled' ); 7416 } ); 7417 section.toggleClass( 'open' ); 7418 $( this ).attr( 'aria-expanded', true ); 7419 } 7420 }); 7421 7422 /** 7423 * Initialize Previewer 7424 * 7425 * @alias wp.customize.previewer 7426 */ 7427 api.previewer = new api.Previewer({ 7428 container: '#customize-preview', 7429 form: '#customize-controls', 7430 previewUrl: api.settings.url.preview, 7431 allowedUrls: api.settings.url.allowed 7432 },/** @lends wp.customize.previewer */{ 7433 7434 nonce: api.settings.nonce, 7435 7436 /** 7437 * Build the query to send along with the Preview request. 7438 * 7439 * @since 3.4.0 7440 * @since 4.7.0 Added options param. 7441 * @access public 7442 * 7443 * @param {Object} [options] Options. 7444 * @param {boolean} [options.excludeCustomizedSaved=false] Exclude saved settings in customized response (values pending writing to changeset). 7445 * @return {Object} Query vars. 7446 */ 7447 query: function( options ) { 7448 var queryVars = { 7449 wp_customize: 'on', 7450 customize_theme: api.settings.theme.stylesheet, 7451 nonce: this.nonce.preview, 7452 customize_changeset_uuid: api.settings.changeset.uuid 7453 }; 7454 if ( api.settings.changeset.autosaved || ! api.state( 'saved' ).get() ) { 7455 queryVars.customize_autosaved = 'on'; 7456 } 7457 7458 /* 7459 * Exclude customized data if requested especially for calls to requestChangesetUpdate. 7460 * Changeset updates are differential and so it is a performance waste to send all of 7461 * the dirty settings with each update. 7462 */ 7463 queryVars.customized = JSON.stringify( api.dirtyValues( { 7464 unsaved: options && options.excludeCustomizedSaved 7465 } ) ); 7466 7467 return queryVars; 7468 }, 7469 7470 /** 7471 * Save (and publish) the customizer changeset. 7472 * 7473 * Updates to the changeset are transactional. If any of the settings 7474 * are invalid then none of them will be written into the changeset. 7475 * A revision will be made for the changeset post if revisions support 7476 * has been added to the post type. 7477 * 7478 * @since 3.4.0 7479 * @since 4.7.0 Added args param and return value. 7480 * 7481 * @param {Object} [args] Args. 7482 * @param {string} [args.status=publish] Status. 7483 * @param {string} [args.date] Date, in local time in MySQL format. 7484 * @param {string} [args.title] Title 7485 * @return {jQuery.promise} Promise. 7486 */ 7487 save: function( args ) { 7488 var previewer = this, 7489 deferred = $.Deferred(), 7490 changesetStatus = api.state( 'selectedChangesetStatus' ).get(), 7491 selectedChangesetDate = api.state( 'selectedChangesetDate' ).get(), 7492 processing = api.state( 'processing' ), 7493 submitWhenDoneProcessing, 7494 submit, 7495 modifiedWhileSaving = {}, 7496 invalidSettings = [], 7497 invalidControls = [], 7498 invalidSettingLessControls = []; 7499 7500 if ( args && args.status ) { 7501 changesetStatus = args.status; 7502 } 7503 7504 if ( api.state( 'saving' ).get() ) { 7505 deferred.reject( 'already_saving' ); 7506 deferred.promise(); 7507 } 7508 7509 api.state( 'saving' ).set( true ); 7510 7511 function captureSettingModifiedDuringSave( setting ) { 7512 modifiedWhileSaving[ setting.id ] = true; 7513 } 7514 7515 submit = function () { 7516 var request, query, settingInvalidities = {}, latestRevision = api._latestRevision, errorCode = 'client_side_error'; 7517 7518 api.bind( 'change', captureSettingModifiedDuringSave ); 7519 api.notifications.remove( errorCode ); 7520 7521 /* 7522 * Block saving if there are any settings that are marked as 7523 * invalid from the client (not from the server). Focus on 7524 * the control. 7525 */ 7526 api.each( function( setting ) { 7527 setting.notifications.each( function( notification ) { 7528 if ( 'error' === notification.type && ! notification.fromServer ) { 7529 invalidSettings.push( setting.id ); 7530 if ( ! settingInvalidities[ setting.id ] ) { 7531 settingInvalidities[ setting.id ] = {}; 7532 } 7533 settingInvalidities[ setting.id ][ notification.code ] = notification; 7534 } 7535 } ); 7536 } ); 7537 7538 // Find all invalid setting less controls with notification type error. 7539 api.control.each( function( control ) { 7540 if ( ! control.setting || ! control.setting.id && control.active.get() ) { 7541 control.notifications.each( function( notification ) { 7542 if ( 'error' === notification.type ) { 7543 invalidSettingLessControls.push( [ control ] ); 7544 } 7545 } ); 7546 } 7547 } ); 7548 7549 invalidControls = _.union( invalidSettingLessControls, _.values( api.findControlsForSettings( invalidSettings ) ) ); 7550 if ( ! _.isEmpty( invalidControls ) ) { 7551 7552 invalidControls[0][0].focus(); 7553 api.unbind( 'change', captureSettingModifiedDuringSave ); 7554 7555 if ( invalidSettings.length ) { 7556 api.notifications.add( new api.Notification( errorCode, { 7557 message: ( 1 === invalidSettings.length ? api.l10n.saveBlockedError.singular : api.l10n.saveBlockedError.plural ).replace( /%s/g, String( invalidSettings.length ) ), 7558 type: 'error', 7559 dismissible: true, 7560 saveFailure: true 7561 } ) ); 7562 } 7563 7564 deferred.rejectWith( previewer, [ 7565 { setting_invalidities: settingInvalidities } 7566 ] ); 7567 api.state( 'saving' ).set( false ); 7568 return deferred.promise(); 7569 } 7570 7571 /* 7572 * Note that excludeCustomizedSaved is intentionally false so that the entire 7573 * set of customized data will be included if bypassed changeset update. 7574 */ 7575 query = $.extend( previewer.query( { excludeCustomizedSaved: false } ), { 7576 nonce: previewer.nonce.save, 7577 customize_changeset_status: changesetStatus 7578 } ); 7579 7580 if ( args && args.date ) { 7581 query.customize_changeset_date = args.date; 7582 } else if ( 'future' === changesetStatus && selectedChangesetDate ) { 7583 query.customize_changeset_date = selectedChangesetDate; 7584 } 7585 7586 if ( args && args.title ) { 7587 query.customize_changeset_title = args.title; 7588 } 7589 7590 // Allow plugins to modify the params included with the save request. 7591 api.trigger( 'save-request-params', query ); 7592 7593 /* 7594 * Note that the dirty customized values will have already been set in the 7595 * changeset and so technically query.customized could be deleted. However, 7596 * it is remaining here to make sure that any settings that got updated 7597 * quietly which may have not triggered an update request will also get 7598 * included in the values that get saved to the changeset. This will ensure 7599 * that values that get injected via the saved event will be included in 7600 * the changeset. This also ensures that setting values that were invalid 7601 * will get re-validated, perhaps in the case of settings that are invalid 7602 * due to dependencies on other settings. 7603 */ 7604 request = wp.ajax.post( 'customize_save', query ); 7605 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 7606 7607 api.trigger( 'save', request ); 7608 7609 request.always( function () { 7610 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 7611 api.state( 'saving' ).set( false ); 7612 api.unbind( 'change', captureSettingModifiedDuringSave ); 7613 } ); 7614 7615 // Remove notifications that were added due to save failures. 7616 api.notifications.each( function( notification ) { 7617 if ( notification.saveFailure ) { 7618 api.notifications.remove( notification.code ); 7619 } 7620 }); 7621 7622 request.fail( function ( response ) { 7623 var notification, notificationArgs; 7624 notificationArgs = { 7625 type: 'error', 7626 dismissible: true, 7627 fromServer: true, 7628 saveFailure: true 7629 }; 7630 7631 if ( '0' === response ) { 7632 response = 'not_logged_in'; 7633 } else if ( '-1' === response ) { 7634 // Back-compat in case any other check_ajax_referer() call is dying. 7635 response = 'invalid_nonce'; 7636 } 7637 7638 if ( 'invalid_nonce' === response ) { 7639 previewer.cheatin(); 7640 } else if ( 'not_logged_in' === response ) { 7641 previewer.preview.iframe.hide(); 7642 previewer.login().done( function() { 7643 previewer.save(); 7644 previewer.preview.iframe.show(); 7645 } ); 7646 } else if ( response.code ) { 7647 if ( 'not_future_date' === response.code && api.section.has( 'publish_settings' ) && api.section( 'publish_settings' ).active.get() && api.control.has( 'changeset_scheduled_date' ) ) { 7648 api.control( 'changeset_scheduled_date' ).toggleFutureDateNotification( true ).focus(); 7649 } else if ( 'changeset_locked' !== response.code ) { 7650 notification = new api.Notification( response.code, _.extend( notificationArgs, { 7651 message: response.message 7652 } ) ); 7653 } 7654 } else { 7655 notification = new api.Notification( 'unknown_error', _.extend( notificationArgs, { 7656 message: api.l10n.unknownRequestFail 7657 } ) ); 7658 } 7659 7660 if ( notification ) { 7661 api.notifications.add( notification ); 7662 } 7663 7664 if ( response.setting_validities ) { 7665 api._handleSettingValidities( { 7666 settingValidities: response.setting_validities, 7667 focusInvalidControl: true 7668 } ); 7669 } 7670 7671 deferred.rejectWith( previewer, [ response ] ); 7672 api.trigger( 'error', response ); 7673 7674 // Start a new changeset if the underlying changeset was published. 7675 if ( 'changeset_already_published' === response.code && response.next_changeset_uuid ) { 7676 api.settings.changeset.uuid = response.next_changeset_uuid; 7677 api.state( 'changesetStatus' ).set( '' ); 7678 if ( api.settings.changeset.branching ) { 7679 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 7680 } 7681 api.previewer.send( 'changeset-uuid', api.settings.changeset.uuid ); 7682 } 7683 } ); 7684 7685 request.done( function( response ) { 7686 7687 previewer.send( 'saved', response ); 7688 7689 api.state( 'changesetStatus' ).set( response.changeset_status ); 7690 if ( response.changeset_date ) { 7691 api.state( 'changesetDate' ).set( response.changeset_date ); 7692 } 7693 7694 if ( 'publish' === response.changeset_status ) { 7695 7696 // Mark all published as clean if they haven't been modified during the request. 7697 api.each( function( setting ) { 7698 /* 7699 * Note that the setting revision will be undefined in the case of setting 7700 * values that are marked as dirty when the customizer is loaded, such as 7701 * when applying starter content. All other dirty settings will have an 7702 * associated revision due to their modification triggering a change event. 7703 */ 7704 if ( setting._dirty && ( _.isUndefined( api._latestSettingRevisions[ setting.id ] ) || api._latestSettingRevisions[ setting.id ] <= latestRevision ) ) { 7705 setting._dirty = false; 7706 } 7707 } ); 7708 7709 api.state( 'changesetStatus' ).set( '' ); 7710 api.settings.changeset.uuid = response.next_changeset_uuid; 7711 if ( api.settings.changeset.branching ) { 7712 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 7713 } 7714 } 7715 7716 // Prevent subsequent requestChangesetUpdate() calls from including the settings that have been saved. 7717 api._lastSavedRevision = Math.max( latestRevision, api._lastSavedRevision ); 7718 7719 if ( response.setting_validities ) { 7720 api._handleSettingValidities( { 7721 settingValidities: response.setting_validities, 7722 focusInvalidControl: true 7723 } ); 7724 } 7725 7726 deferred.resolveWith( previewer, [ response ] ); 7727 api.trigger( 'saved', response ); 7728 7729 // Restore the global dirty state if any settings were modified during save. 7730 if ( ! _.isEmpty( modifiedWhileSaving ) ) { 7731 api.state( 'saved' ).set( false ); 7732 } 7733 } ); 7734 }; 7735 7736 if ( 0 === processing() ) { 7737 submit(); 7738 } else { 7739 submitWhenDoneProcessing = function () { 7740 if ( 0 === processing() ) { 7741 api.state.unbind( 'change', submitWhenDoneProcessing ); 7742 submit(); 7743 } 7744 }; 7745 api.state.bind( 'change', submitWhenDoneProcessing ); 7746 } 7747 7748 return deferred.promise(); 7749 }, 7750 7751 /** 7752 * Trash the current changes. 7753 * 7754 * Revert the Customizer to its previously-published state. 7755 * 7756 * @since 4.9.0 7757 * 7758 * @return {jQuery.promise} Promise. 7759 */ 7760 trash: function trash() { 7761 var request, success, fail; 7762 7763 api.state( 'trashing' ).set( true ); 7764 api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); 7765 7766 request = wp.ajax.post( 'customize_trash', { 7767 customize_changeset_uuid: api.settings.changeset.uuid, 7768 nonce: api.settings.nonce.trash 7769 } ); 7770 api.notifications.add( new api.OverlayNotification( 'changeset_trashing', { 7771 type: 'info', 7772 message: api.l10n.revertingChanges, 7773 loading: true 7774 } ) ); 7775 7776 success = function() { 7777 var urlParser = document.createElement( 'a' ), queryParams; 7778 7779 api.state( 'changesetStatus' ).set( 'trash' ); 7780 api.each( function( setting ) { 7781 setting._dirty = false; 7782 } ); 7783 api.state( 'saved' ).set( true ); 7784 7785 // Go back to Customizer without changeset. 7786 urlParser.href = location.href; 7787 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 7788 delete queryParams.changeset_uuid; 7789 queryParams['return'] = api.settings.url['return']; 7790 urlParser.search = $.param( queryParams ); 7791 location.replace( urlParser.href ); 7792 }; 7793 7794 fail = function( code, message ) { 7795 var notificationCode = code || 'unknown_error'; 7796 api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); 7797 api.state( 'trashing' ).set( false ); 7798 api.notifications.remove( 'changeset_trashing' ); 7799 api.notifications.add( new api.Notification( notificationCode, { 7800 message: message || api.l10n.unknownError, 7801 dismissible: true, 7802 type: 'error' 7803 } ) ); 7804 }; 7805 7806 request.done( function( response ) { 7807 success( response.message ); 7808 } ); 7809 7810 request.fail( function( response ) { 7811 var code = response.code || 'trashing_failed'; 7812 if ( response.success || 'non_existent_changeset' === code || 'changeset_already_trashed' === code ) { 7813 success( response.message ); 7814 } else { 7815 fail( code, response.message ); 7816 } 7817 } ); 7818 }, 7819 7820 /** 7821 * Builds the front preview URL with the current state of customizer. 7822 * 7823 * @since 4.9.0 7824 * 7825 * @return {string} Preview URL. 7826 */ 7827 getFrontendPreviewUrl: function() { 7828 var previewer = this, params, urlParser; 7829 urlParser = document.createElement( 'a' ); 7830 urlParser.href = previewer.previewUrl.get(); 7831 params = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 7832 7833 if ( api.state( 'changesetStatus' ).get() && 'publish' !== api.state( 'changesetStatus' ).get() ) { 7834 params.customize_changeset_uuid = api.settings.changeset.uuid; 7835 } 7836 if ( ! api.state( 'activated' ).get() ) { 7837 params.customize_theme = api.settings.theme.stylesheet; 7838 } 7839 7840 urlParser.search = $.param( params ); 7841 return urlParser.href; 7842 } 7843 }); 7844 7845 // Ensure preview nonce is included with every customized request, to allow post data to be read. 7846 $.ajaxPrefilter( function injectPreviewNonce( options ) { 7847 if ( ! /wp_customize=on/.test( options.data ) ) { 7848 return; 7849 } 7850 options.data += '&' + $.param({ 7851 customize_preview_nonce: api.settings.nonce.preview 7852 }); 7853 }); 7854 7855 // Refresh the nonces if the preview sends updated nonces over. 7856 api.previewer.bind( 'nonce', function( nonce ) { 7857 $.extend( this.nonce, nonce ); 7858 }); 7859 7860 // Refresh the nonces if login sends updated nonces over. 7861 api.bind( 'nonce-refresh', function( nonce ) { 7862 $.extend( api.settings.nonce, nonce ); 7863 $.extend( api.previewer.nonce, nonce ); 7864 api.previewer.send( 'nonce-refresh', nonce ); 7865 }); 7866 7867 // Create Settings. 7868 $.each( api.settings.settings, function( id, data ) { 7869 var Constructor = api.settingConstructor[ data.type ] || api.Setting; 7870 api.add( new Constructor( id, data.value, { 7871 transport: data.transport, 7872 previewer: api.previewer, 7873 dirty: !! data.dirty 7874 } ) ); 7875 }); 7876 7877 // Create Panels. 7878 $.each( api.settings.panels, function ( id, data ) { 7879 var Constructor = api.panelConstructor[ data.type ] || api.Panel, options; 7880 // Inclusion of params alias is for back-compat for custom panels that expect to augment this property. 7881 options = _.extend( { params: data }, data ); 7882 api.panel.add( new Constructor( id, options ) ); 7883 }); 7884 7885 // Create Sections. 7886 $.each( api.settings.sections, function ( id, data ) { 7887 var Constructor = api.sectionConstructor[ data.type ] || api.Section, options; 7888 // Inclusion of params alias is for back-compat for custom sections that expect to augment this property. 7889 options = _.extend( { params: data }, data ); 7890 api.section.add( new Constructor( id, options ) ); 7891 }); 7892 7893 // Create Controls. 7894 $.each( api.settings.controls, function( id, data ) { 7895 var Constructor = api.controlConstructor[ data.type ] || api.Control, options; 7896 // Inclusion of params alias is for back-compat for custom controls that expect to augment this property. 7897 options = _.extend( { params: data }, data ); 7898 api.control.add( new Constructor( id, options ) ); 7899 }); 7900 7901 // Focus the autofocused element. 7902 _.each( [ 'panel', 'section', 'control' ], function( type ) { 7903 var id = api.settings.autofocus[ type ]; 7904 if ( ! id ) { 7905 return; 7906 } 7907 7908 /* 7909 * Defer focus until: 7910 * 1. The panel, section, or control exists (especially for dynamically-created ones). 7911 * 2. The instance is embedded in the document (and so is focusable). 7912 * 3. The preview has finished loading so that the active states have been set. 7913 */ 7914 api[ type ]( id, function( instance ) { 7915 instance.deferred.embedded.done( function() { 7916 api.previewer.deferred.active.done( function() { 7917 instance.focus(); 7918 }); 7919 }); 7920 }); 7921 }); 7922 7923 api.bind( 'ready', api.reflowPaneContents ); 7924 $( [ api.panel, api.section, api.control ] ).each( function ( i, values ) { 7925 var debouncedReflowPaneContents = _.debounce( api.reflowPaneContents, api.settings.timeouts.reflowPaneContents ); 7926 values.bind( 'add', debouncedReflowPaneContents ); 7927 values.bind( 'change', debouncedReflowPaneContents ); 7928 values.bind( 'remove', debouncedReflowPaneContents ); 7929 } ); 7930 7931 // Set up global notifications area. 7932 api.bind( 'ready', function setUpGlobalNotificationsArea() { 7933 var sidebar, containerHeight, containerInitialTop; 7934 api.notifications.container = $( '#customize-notifications-area' ); 7935 7936 api.notifications.bind( 'change', _.debounce( function() { 7937 api.notifications.render(); 7938 } ) ); 7939 7940 sidebar = $( '.wp-full-overlay-sidebar-content' ); 7941 api.notifications.bind( 'rendered', function updateSidebarTop() { 7942 sidebar.css( 'top', '' ); 7943 if ( 0 !== api.notifications.count() ) { 7944 containerHeight = api.notifications.container.outerHeight() + 1; 7945 containerInitialTop = parseInt( sidebar.css( 'top' ), 10 ); 7946 sidebar.css( 'top', containerInitialTop + containerHeight + 'px' ); 7947 } 7948 api.notifications.trigger( 'sidebarTopUpdated' ); 7949 }); 7950 7951 api.notifications.render(); 7952 }); 7953 7954 // Save and activated states. 7955 (function( state ) { 7956 var saved = state.instance( 'saved' ), 7957 saving = state.instance( 'saving' ), 7958 trashing = state.instance( 'trashing' ), 7959 activated = state.instance( 'activated' ), 7960 processing = state.instance( 'processing' ), 7961 paneVisible = state.instance( 'paneVisible' ), 7962 expandedPanel = state.instance( 'expandedPanel' ), 7963 expandedSection = state.instance( 'expandedSection' ), 7964 changesetStatus = state.instance( 'changesetStatus' ), 7965 selectedChangesetStatus = state.instance( 'selectedChangesetStatus' ), 7966 changesetDate = state.instance( 'changesetDate' ), 7967 selectedChangesetDate = state.instance( 'selectedChangesetDate' ), 7968 previewerAlive = state.instance( 'previewerAlive' ), 7969 editShortcutVisibility = state.instance( 'editShortcutVisibility' ), 7970 changesetLocked = state.instance( 'changesetLocked' ), 7971 populateChangesetUuidParam, defaultSelectedChangesetStatus; 7972 7973 state.bind( 'change', function() { 7974 var canSave; 7975 7976 if ( ! activated() ) { 7977 saveBtn.val( api.l10n.activate ); 7978 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 7979 7980 } else if ( '' === changesetStatus.get() && saved() ) { 7981 if ( api.settings.changeset.currentUserCanPublish ) { 7982 saveBtn.val( api.l10n.published ); 7983 } else { 7984 saveBtn.val( api.l10n.saved ); 7985 } 7986 closeBtn.find( '.screen-reader-text' ).text( api.l10n.close ); 7987 7988 } else { 7989 if ( 'draft' === selectedChangesetStatus() ) { 7990 if ( saved() && selectedChangesetStatus() === changesetStatus() ) { 7991 saveBtn.val( api.l10n.draftSaved ); 7992 } else { 7993 saveBtn.val( api.l10n.saveDraft ); 7994 } 7995 } else if ( 'future' === selectedChangesetStatus() ) { 7996 if ( saved() && selectedChangesetStatus() === changesetStatus() ) { 7997 if ( changesetDate.get() !== selectedChangesetDate.get() ) { 7998 saveBtn.val( api.l10n.schedule ); 7999 } else { 8000 saveBtn.val( api.l10n.scheduled ); 8001 } 8002 } else { 8003 saveBtn.val( api.l10n.schedule ); 8004 } 8005 } else if ( api.settings.changeset.currentUserCanPublish ) { 8006 saveBtn.val( api.l10n.publish ); 8007 } 8008 closeBtn.find( '.screen-reader-text' ).text( api.l10n.cancel ); 8009 } 8010 8011 /* 8012 * Save (publish) button should be enabled if saving is not currently happening, 8013 * and if the theme is not active or the changeset exists but is not published. 8014 */ 8015 canSave = ! saving() && ! trashing() && ! changesetLocked() && ( ! activated() || ! saved() || ( changesetStatus() !== selectedChangesetStatus() && '' !== changesetStatus() ) || ( 'future' === selectedChangesetStatus() && changesetDate.get() !== selectedChangesetDate.get() ) ); 8016 8017 saveBtn.prop( 'disabled', ! canSave ); 8018 }); 8019 8020 selectedChangesetStatus.validate = function( status ) { 8021 if ( '' === status || 'auto-draft' === status ) { 8022 return null; 8023 } 8024 return status; 8025 }; 8026 8027 defaultSelectedChangesetStatus = api.settings.changeset.currentUserCanPublish ? 'publish' : 'draft'; 8028 8029 // Set default states. 8030 changesetStatus( api.settings.changeset.status ); 8031 changesetLocked( Boolean( api.settings.changeset.lockUser ) ); 8032 changesetDate( api.settings.changeset.publishDate ); 8033 selectedChangesetDate( api.settings.changeset.publishDate ); 8034 selectedChangesetStatus( '' === api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ? defaultSelectedChangesetStatus : api.settings.changeset.status ); 8035 selectedChangesetStatus.link( changesetStatus ); // Ensure that direct updates to status on server via wp.customizer.previewer.save() will update selection. 8036 saved( true ); 8037 if ( '' === changesetStatus() ) { // Handle case for loading starter content. 8038 api.each( function( setting ) { 8039 if ( setting._dirty ) { 8040 saved( false ); 8041 } 8042 } ); 8043 } 8044 saving( false ); 8045 activated( api.settings.theme.active ); 8046 processing( 0 ); 8047 paneVisible( true ); 8048 expandedPanel( false ); 8049 expandedSection( false ); 8050 previewerAlive( true ); 8051 editShortcutVisibility( 'visible' ); 8052 8053 api.bind( 'change', function() { 8054 if ( state( 'saved' ).get() ) { 8055 state( 'saved' ).set( false ); 8056 } 8057 }); 8058 8059 // Populate changeset UUID param when state becomes dirty. 8060 if ( api.settings.changeset.branching ) { 8061 saved.bind( function( isSaved ) { 8062 if ( ! isSaved ) { 8063 populateChangesetUuidParam( true ); 8064 } 8065 }); 8066 } 8067 8068 saving.bind( function( isSaving ) { 8069 body.toggleClass( 'saving', isSaving ); 8070 } ); 8071 trashing.bind( function( isTrashing ) { 8072 body.toggleClass( 'trashing', isTrashing ); 8073 } ); 8074 8075 api.bind( 'saved', function( response ) { 8076 state('saved').set( true ); 8077 if ( 'publish' === response.changeset_status ) { 8078 state( 'activated' ).set( true ); 8079 } 8080 }); 8081 8082 activated.bind( function( to ) { 8083 if ( to ) { 8084 api.trigger( 'activated' ); 8085 } 8086 }); 8087 8088 /** 8089 * Populate URL with UUID via `history.replaceState()`. 8090 * 8091 * @since 4.7.0 8092 * @access private 8093 * 8094 * @param {boolean} isIncluded Is UUID included. 8095 * @return {void} 8096 */ 8097 populateChangesetUuidParam = function( isIncluded ) { 8098 var urlParser, queryParams; 8099 8100 // Abort on IE9 which doesn't support history management. 8101 if ( ! history.replaceState ) { 8102 return; 8103 } 8104 8105 urlParser = document.createElement( 'a' ); 8106 urlParser.href = location.href; 8107 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8108 if ( isIncluded ) { 8109 if ( queryParams.changeset_uuid === api.settings.changeset.uuid ) { 8110 return; 8111 } 8112 queryParams.changeset_uuid = api.settings.changeset.uuid; 8113 } else { 8114 if ( ! queryParams.changeset_uuid ) { 8115 return; 8116 } 8117 delete queryParams.changeset_uuid; 8118 } 8119 urlParser.search = $.param( queryParams ); 8120 history.replaceState( {}, document.title, urlParser.href ); 8121 }; 8122 8123 // Show changeset UUID in URL when in branching mode and there is a saved changeset. 8124 if ( api.settings.changeset.branching ) { 8125 changesetStatus.bind( function( newStatus ) { 8126 populateChangesetUuidParam( '' !== newStatus && 'publish' !== newStatus && 'trash' !== newStatus ); 8127 } ); 8128 } 8129 }( api.state ) ); 8130 8131 /** 8132 * Handles lock notice and take over request. 8133 * 8134 * @since 4.9.0 8135 */ 8136 ( function checkAndDisplayLockNotice() { 8137 8138 var LockedNotification = api.OverlayNotification.extend(/** @lends wp.customize~LockedNotification.prototype */{ 8139 8140 /** 8141 * Template ID. 8142 * 8143 * @type {string} 8144 */ 8145 templateId: 'customize-changeset-locked-notification', 8146 8147 /** 8148 * Lock user. 8149 * 8150 * @type {object} 8151 */ 8152 lockUser: null, 8153 8154 /** 8155 * A notification that is displayed in a full-screen overlay with information about the locked changeset. 8156 * 8157 * @constructs wp.customize~LockedNotification 8158 * @augments wp.customize.OverlayNotification 8159 * 8160 * @since 4.9.0 8161 * 8162 * @param {string} [code] - Code. 8163 * @param {Object} [params] - Params. 8164 */ 8165 initialize: function( code, params ) { 8166 var notification = this, _code, _params; 8167 _code = code || 'changeset_locked'; 8168 _params = _.extend( 8169 { 8170 message: '', 8171 type: 'warning', 8172 containerClasses: '', 8173 lockUser: {} 8174 }, 8175 params 8176 ); 8177 _params.containerClasses += ' notification-changeset-locked'; 8178 api.OverlayNotification.prototype.initialize.call( notification, _code, _params ); 8179 }, 8180 8181 /** 8182 * Render notification. 8183 * 8184 * @since 4.9.0 8185 * 8186 * @return {jQuery} Notification container. 8187 */ 8188 render: function() { 8189 var notification = this, li, data, takeOverButton, request; 8190 data = _.extend( 8191 { 8192 allowOverride: false, 8193 returnUrl: api.settings.url['return'], 8194 previewUrl: api.previewer.previewUrl.get(), 8195 frontendPreviewUrl: api.previewer.getFrontendPreviewUrl() 8196 }, 8197 this 8198 ); 8199 8200 li = api.OverlayNotification.prototype.render.call( data ); 8201 8202 // Try to autosave the changeset now. 8203 api.requestChangesetUpdate( {}, { autosave: true } ).fail( function( response ) { 8204 if ( ! response.autosaved ) { 8205 li.find( '.notice-error' ).prop( 'hidden', false ).text( response.message || api.l10n.unknownRequestFail ); 8206 } 8207 } ); 8208 8209 takeOverButton = li.find( '.customize-notice-take-over-button' ); 8210 takeOverButton.on( 'click', function( event ) { 8211 event.preventDefault(); 8212 if ( request ) { 8213 return; 8214 } 8215 8216 takeOverButton.addClass( 'disabled' ); 8217 request = wp.ajax.post( 'customize_override_changeset_lock', { 8218 wp_customize: 'on', 8219 customize_theme: api.settings.theme.stylesheet, 8220 customize_changeset_uuid: api.settings.changeset.uuid, 8221 nonce: api.settings.nonce.override_lock 8222 } ); 8223 8224 request.done( function() { 8225 api.notifications.remove( notification.code ); // Remove self. 8226 api.state( 'changesetLocked' ).set( false ); 8227 } ); 8228 8229 request.fail( function( response ) { 8230 var message = response.message || api.l10n.unknownRequestFail; 8231 li.find( '.notice-error' ).prop( 'hidden', false ).text( message ); 8232 8233 request.always( function() { 8234 takeOverButton.removeClass( 'disabled' ); 8235 } ); 8236 } ); 8237 8238 request.always( function() { 8239 request = null; 8240 } ); 8241 } ); 8242 8243 return li; 8244 } 8245 }); 8246 8247 /** 8248 * Start lock. 8249 * 8250 * @since 4.9.0 8251 * 8252 * @param {Object} [args] - Args. 8253 * @param {Object} [args.lockUser] - Lock user data. 8254 * @param {boolean} [args.allowOverride=false] - Whether override is allowed. 8255 * @return {void} 8256 */ 8257 function startLock( args ) { 8258 if ( args && args.lockUser ) { 8259 api.settings.changeset.lockUser = args.lockUser; 8260 } 8261 api.state( 'changesetLocked' ).set( true ); 8262 api.notifications.add( new LockedNotification( 'changeset_locked', { 8263 lockUser: api.settings.changeset.lockUser, 8264 allowOverride: Boolean( args && args.allowOverride ) 8265 } ) ); 8266 } 8267 8268 // Show initial notification. 8269 if ( api.settings.changeset.lockUser ) { 8270 startLock( { allowOverride: true } ); 8271 } 8272 8273 // Check for lock when sending heartbeat requests. 8274 $( document ).on( 'heartbeat-send.update_lock_notice', function( event, data ) { 8275 data.check_changeset_lock = true; 8276 data.changeset_uuid = api.settings.changeset.uuid; 8277 } ); 8278 8279 // Handle heartbeat ticks. 8280 $( document ).on( 'heartbeat-tick.update_lock_notice', function( event, data ) { 8281 var notification, code = 'changeset_locked'; 8282 if ( ! data.customize_changeset_lock_user ) { 8283 return; 8284 } 8285 8286 // Update notification when a different user takes over. 8287 notification = api.notifications( code ); 8288 if ( notification && notification.lockUser.id !== api.settings.changeset.lockUser.id ) { 8289 api.notifications.remove( code ); 8290 } 8291 8292 startLock( { 8293 lockUser: data.customize_changeset_lock_user 8294 } ); 8295 } ); 8296 8297 // Handle locking in response to changeset save errors. 8298 api.bind( 'error', function( response ) { 8299 if ( 'changeset_locked' === response.code && response.lock_user ) { 8300 startLock( { 8301 lockUser: response.lock_user 8302 } ); 8303 } 8304 } ); 8305 } )(); 8306 8307 // Set up initial notifications. 8308 (function() { 8309 var removedQueryParams = [], autosaveDismissed = false; 8310 8311 /** 8312 * Obtain the URL to restore the autosave. 8313 * 8314 * @return {string} Customizer URL. 8315 */ 8316 function getAutosaveRestorationUrl() { 8317 var urlParser, queryParams; 8318 urlParser = document.createElement( 'a' ); 8319 urlParser.href = location.href; 8320 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8321 if ( api.settings.changeset.latestAutoDraftUuid ) { 8322 queryParams.changeset_uuid = api.settings.changeset.latestAutoDraftUuid; 8323 } else { 8324 queryParams.customize_autosaved = 'on'; 8325 } 8326 queryParams['return'] = api.settings.url['return']; 8327 urlParser.search = $.param( queryParams ); 8328 return urlParser.href; 8329 } 8330 8331 /** 8332 * Remove parameter from the URL. 8333 * 8334 * @param {Array} params - Parameter names to remove. 8335 * @return {void} 8336 */ 8337 function stripParamsFromLocation( params ) { 8338 var urlParser = document.createElement( 'a' ), queryParams, strippedParams = 0; 8339 urlParser.href = location.href; 8340 queryParams = api.utils.parseQueryString( urlParser.search.substr( 1 ) ); 8341 _.each( params, function( param ) { 8342 if ( 'undefined' !== typeof queryParams[ param ] ) { 8343 strippedParams += 1; 8344 delete queryParams[ param ]; 8345 } 8346 } ); 8347 if ( 0 === strippedParams ) { 8348 return; 8349 } 8350 8351 urlParser.search = $.param( queryParams ); 8352 history.replaceState( {}, document.title, urlParser.href ); 8353 } 8354 8355 /** 8356 * Displays a Site Editor notification when a block theme is activated. 8357 * 8358 * @since 4.9.0 8359 * 8360 * @param {string} [notification] - A notification to display. 8361 * @return {void} 8362 */ 8363 function addSiteEditorNotification( notification ) { 8364 api.notifications.add( new api.Notification( 'site_editor_block_theme_notice', { 8365 message: notification, 8366 type: 'info', 8367 dismissible: false, 8368 render: function() { 8369 var notification = api.Notification.prototype.render.call( this ), 8370 button = notification.find( 'button.switch-to-editor' ); 8371 8372 button.on( 'click', function( event ) { 8373 event.preventDefault(); 8374 location.assign( button.data( 'action' ) ); 8375 } ); 8376 8377 return notification; 8378 } 8379 } ) ); 8380 } 8381 8382 /** 8383 * Dismiss autosave. 8384 * 8385 * @return {void} 8386 */ 8387 function dismissAutosave() { 8388 if ( autosaveDismissed ) { 8389 return; 8390 } 8391 wp.ajax.post( 'customize_dismiss_autosave_or_lock', { 8392 wp_customize: 'on', 8393 customize_theme: api.settings.theme.stylesheet, 8394 customize_changeset_uuid: api.settings.changeset.uuid, 8395 nonce: api.settings.nonce.dismiss_autosave_or_lock, 8396 dismiss_autosave: true 8397 } ); 8398 autosaveDismissed = true; 8399 } 8400 8401 /** 8402 * Add notification regarding the availability of an autosave to restore. 8403 * 8404 * @return {void} 8405 */ 8406 function addAutosaveRestoreNotification() { 8407 var code = 'autosave_available', onStateChange; 8408 8409 // Since there is an autosave revision and the user hasn't loaded with autosaved, add notification to prompt to load autosaved version. 8410 api.notifications.add( new api.Notification( code, { 8411 message: api.l10n.autosaveNotice, 8412 type: 'warning', 8413 dismissible: true, 8414 render: function() { 8415 var li = api.Notification.prototype.render.call( this ), link; 8416 8417 // Handle clicking on restoration link. 8418 link = li.find( 'a' ); 8419 link.prop( 'href', getAutosaveRestorationUrl() ); 8420 link.on( 'click', function( event ) { 8421 event.preventDefault(); 8422 location.replace( getAutosaveRestorationUrl() ); 8423 } ); 8424 8425 // Handle dismissal of notice. 8426 li.find( '.notice-dismiss' ).on( 'click', dismissAutosave ); 8427 8428 return li; 8429 } 8430 } ) ); 8431 8432 // Remove the notification once the user starts making changes. 8433 onStateChange = function() { 8434 dismissAutosave(); 8435 api.notifications.remove( code ); 8436 api.unbind( 'change', onStateChange ); 8437 api.state( 'changesetStatus' ).unbind( onStateChange ); 8438 }; 8439 api.bind( 'change', onStateChange ); 8440 api.state( 'changesetStatus' ).bind( onStateChange ); 8441 } 8442 8443 if ( api.settings.changeset.autosaved ) { 8444 api.state( 'saved' ).set( false ); 8445 removedQueryParams.push( 'customize_autosaved' ); 8446 } 8447 if ( ! api.settings.changeset.branching && ( ! api.settings.changeset.status || 'auto-draft' === api.settings.changeset.status ) ) { 8448 removedQueryParams.push( 'changeset_uuid' ); // Remove UUID when restoring autosave auto-draft. 8449 } 8450 if ( removedQueryParams.length > 0 ) { 8451 stripParamsFromLocation( removedQueryParams ); 8452 } 8453 if ( api.settings.changeset.latestAutoDraftUuid || api.settings.changeset.hasAutosaveRevision ) { 8454 addAutosaveRestoreNotification(); 8455 } 8456 var shouldDisplayBlockThemeNotification = !! parseInt( $( '#customize-info' ).data( 'block-theme' ), 10 ); 8457 if (shouldDisplayBlockThemeNotification) { 8458 addSiteEditorNotification( api.l10n.blockThemeNotification ); 8459 } 8460 })(); 8461 8462 // Check if preview url is valid and load the preview frame. 8463 if ( api.previewer.previewUrl() ) { 8464 api.previewer.refresh(); 8465 } else { 8466 api.previewer.previewUrl( api.settings.url.home ); 8467 } 8468 8469 // Button bindings. 8470 saveBtn.on( 'click', function( event ) { 8471 api.previewer.save(); 8472 event.preventDefault(); 8473 }).on( 'keydown', function( event ) { 8474 if ( 9 === event.which ) { // Tab. 8475 return; 8476 } 8477 if ( 13 === event.which ) { // Enter. 8478 api.previewer.save(); 8479 } 8480 event.preventDefault(); 8481 }); 8482 8483 closeBtn.on( 'keydown', function( event ) { 8484 if ( 9 === event.which ) { // Tab. 8485 return; 8486 } 8487 if ( 13 === event.which ) { // Enter. 8488 this.click(); 8489 } 8490 event.preventDefault(); 8491 }); 8492 8493 $( '.collapse-sidebar' ).on( 'click', function() { 8494 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() ); 8495 }); 8496 8497 api.state( 'paneVisible' ).bind( function( paneVisible ) { 8498 overlay.toggleClass( 'preview-only', ! paneVisible ); 8499 overlay.toggleClass( 'expanded', paneVisible ); 8500 overlay.toggleClass( 'collapsed', ! paneVisible ); 8501 8502 if ( ! paneVisible ) { 8503 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'false', 'aria-label': api.l10n.expandSidebar }); 8504 } else { 8505 $( '.collapse-sidebar' ).attr({ 'aria-expanded': 'true', 'aria-label': api.l10n.collapseSidebar }); 8506 } 8507 }); 8508 8509 // Keyboard shortcuts - esc to exit section/panel. 8510 body.on( 'keydown', function( event ) { 8511 var collapsedObject, expandedControls = [], expandedSections = [], expandedPanels = []; 8512 8513 if ( 27 !== event.which ) { // Esc. 8514 return; 8515 } 8516 8517 /* 8518 * Abort if the event target is not the body (the default) and not inside of #customize-controls. 8519 * This ensures that ESC meant to collapse a modal dialog or a TinyMCE toolbar won't collapse something else. 8520 */ 8521 if ( ! $( event.target ).is( 'body' ) && ! $.contains( $( '#customize-controls' )[0], event.target ) ) { 8522 return; 8523 } 8524 8525 // Abort if we're inside of a block editor instance. 8526 if ( event.target.closest( '.block-editor-writing-flow' ) !== null || 8527 event.target.closest( '.block-editor-block-list__block-popover' ) !== null 8528 ) { 8529 return; 8530 } 8531 8532 // Check for expanded expandable controls (e.g. widgets and nav menus items), sections, and panels. 8533 api.control.each( function( control ) { 8534 if ( control.expanded && control.expanded() && _.isFunction( control.collapse ) ) { 8535 expandedControls.push( control ); 8536 } 8537 }); 8538 api.section.each( function( section ) { 8539 if ( section.expanded() ) { 8540 expandedSections.push( section ); 8541 } 8542 }); 8543 api.panel.each( function( panel ) { 8544 if ( panel.expanded() ) { 8545 expandedPanels.push( panel ); 8546 } 8547 }); 8548 8549 // Skip collapsing expanded controls if there are no expanded sections. 8550 if ( expandedControls.length > 0 && 0 === expandedSections.length ) { 8551 expandedControls.length = 0; 8552 } 8553 8554 // Collapse the most granular expanded object. 8555 collapsedObject = expandedControls[0] || expandedSections[0] || expandedPanels[0]; 8556 if ( collapsedObject ) { 8557 if ( 'themes' === collapsedObject.params.type ) { 8558 8559 // Themes panel or section. 8560 if ( body.hasClass( 'modal-open' ) ) { 8561 collapsedObject.closeDetails(); 8562 } else if ( api.panel.has( 'themes' ) ) { 8563 8564 // If we're collapsing a section, collapse the panel also. 8565 api.panel( 'themes' ).collapse(); 8566 } 8567 return; 8568 } 8569 collapsedObject.collapse(); 8570 event.preventDefault(); 8571 } 8572 }); 8573 8574 $( '.customize-controls-preview-toggle' ).on( 'click', function() { 8575 api.state( 'paneVisible' ).set( ! api.state( 'paneVisible' ).get() ); 8576 }); 8577 8578 /* 8579 * Sticky header feature. 8580 */ 8581 (function initStickyHeaders() { 8582 var parentContainer = $( '.wp-full-overlay-sidebar-content' ), 8583 changeContainer, updateHeaderHeight, releaseStickyHeader, resetStickyHeader, positionStickyHeader, 8584 activeHeader, lastScrollTop; 8585 8586 /** 8587 * Determine which panel or section is currently expanded. 8588 * 8589 * @since 4.7.0 8590 * @access private 8591 * 8592 * @param {wp.customize.Panel|wp.customize.Section} container Construct. 8593 * @return {void} 8594 */ 8595 changeContainer = function( container ) { 8596 var newInstance = container, 8597 expandedSection = api.state( 'expandedSection' ).get(), 8598 expandedPanel = api.state( 'expandedPanel' ).get(), 8599 headerElement; 8600 8601 if ( activeHeader && activeHeader.element ) { 8602 // Release previously active header element. 8603 releaseStickyHeader( activeHeader.element ); 8604 8605 // Remove event listener in the previous panel or section. 8606 activeHeader.element.find( '.description' ).off( 'toggled', updateHeaderHeight ); 8607 } 8608 8609 if ( ! newInstance ) { 8610 if ( ! expandedSection && expandedPanel && expandedPanel.contentContainer ) { 8611 newInstance = expandedPanel; 8612 } else if ( ! expandedPanel && expandedSection && expandedSection.contentContainer ) { 8613 newInstance = expandedSection; 8614 } else { 8615 activeHeader = false; 8616 return; 8617 } 8618 } 8619 8620 headerElement = newInstance.contentContainer.find( '.customize-section-title, .panel-meta' ).first(); 8621 if ( headerElement.length ) { 8622 activeHeader = { 8623 instance: newInstance, 8624 element: headerElement, 8625 parent: headerElement.closest( '.customize-pane-child' ), 8626 height: headerElement.outerHeight() 8627 }; 8628 8629 // Update header height whenever help text is expanded or collapsed. 8630 activeHeader.element.find( '.description' ).on( 'toggled', updateHeaderHeight ); 8631 8632 if ( expandedSection ) { 8633 resetStickyHeader( activeHeader.element, activeHeader.parent ); 8634 } 8635 } else { 8636 activeHeader = false; 8637 } 8638 }; 8639 api.state( 'expandedSection' ).bind( changeContainer ); 8640 api.state( 'expandedPanel' ).bind( changeContainer ); 8641 8642 // Throttled scroll event handler. 8643 parentContainer.on( 'scroll', _.throttle( function() { 8644 if ( ! activeHeader ) { 8645 return; 8646 } 8647 8648 var scrollTop = parentContainer.scrollTop(), 8649 scrollDirection; 8650 8651 if ( ! lastScrollTop ) { 8652 scrollDirection = 1; 8653 } else { 8654 if ( scrollTop === lastScrollTop ) { 8655 scrollDirection = 0; 8656 } else if ( scrollTop > lastScrollTop ) { 8657 scrollDirection = 1; 8658 } else { 8659 scrollDirection = -1; 8660 } 8661 } 8662 lastScrollTop = scrollTop; 8663 if ( 0 !== scrollDirection ) { 8664 positionStickyHeader( activeHeader, scrollTop, scrollDirection ); 8665 } 8666 }, 8 ) ); 8667 8668 // Update header position on sidebar layout change. 8669 api.notifications.bind( 'sidebarTopUpdated', function() { 8670 if ( activeHeader && activeHeader.element.hasClass( 'is-sticky' ) ) { 8671 activeHeader.element.css( 'top', parentContainer.css( 'top' ) ); 8672 } 8673 }); 8674 8675 // Release header element if it is sticky. 8676 releaseStickyHeader = function( headerElement ) { 8677 if ( ! headerElement.hasClass( 'is-sticky' ) ) { 8678 return; 8679 } 8680 headerElement 8681 .removeClass( 'is-sticky' ) 8682 .addClass( 'maybe-sticky is-in-view' ) 8683 .css( 'top', parentContainer.scrollTop() + 'px' ); 8684 }; 8685 8686 // Reset position of the sticky header. 8687 resetStickyHeader = function( headerElement, headerParent ) { 8688 if ( headerElement.hasClass( 'is-in-view' ) ) { 8689 headerElement 8690 .removeClass( 'maybe-sticky is-in-view' ) 8691 .css( { 8692 width: '', 8693 top: '' 8694 } ); 8695 headerParent.css( 'padding-top', '' ); 8696 } 8697 }; 8698 8699 /** 8700 * Update active header height. 8701 * 8702 * @since 4.7.0 8703 * @access private 8704 * 8705 * @return {void} 8706 */ 8707 updateHeaderHeight = function() { 8708 activeHeader.height = activeHeader.element.outerHeight(); 8709 }; 8710 8711 /** 8712 * Reposition header on throttled `scroll` event. 8713 * 8714 * @since 4.7.0 8715 * @access private 8716 * 8717 * @param {Object} header - Header. 8718 * @param {number} scrollTop - Scroll top. 8719 * @param {number} scrollDirection - Scroll direction, negative number being up and positive being down. 8720 * @return {void} 8721 */ 8722 positionStickyHeader = function( header, scrollTop, scrollDirection ) { 8723 var headerElement = header.element, 8724 headerParent = header.parent, 8725 headerHeight = header.height, 8726 headerTop = parseInt( headerElement.css( 'top' ), 10 ), 8727 maybeSticky = headerElement.hasClass( 'maybe-sticky' ), 8728 isSticky = headerElement.hasClass( 'is-sticky' ), 8729 isInView = headerElement.hasClass( 'is-in-view' ), 8730 isScrollingUp = ( -1 === scrollDirection ); 8731 8732 // When scrolling down, gradually hide sticky header. 8733 if ( ! isScrollingUp ) { 8734 if ( isSticky ) { 8735 headerTop = scrollTop; 8736 headerElement 8737 .removeClass( 'is-sticky' ) 8738 .css( { 8739 top: headerTop + 'px', 8740 width: '' 8741 } ); 8742 } 8743 if ( isInView && scrollTop > headerTop + headerHeight ) { 8744 headerElement.removeClass( 'is-in-view' ); 8745 headerParent.css( 'padding-top', '' ); 8746 } 8747 return; 8748 } 8749 8750 // Scrolling up. 8751 if ( ! maybeSticky && scrollTop >= headerHeight ) { 8752 maybeSticky = true; 8753 headerElement.addClass( 'maybe-sticky' ); 8754 } else if ( 0 === scrollTop ) { 8755 // Reset header in base position. 8756 headerElement 8757 .removeClass( 'maybe-sticky is-in-view is-sticky' ) 8758 .css( { 8759 top: '', 8760 width: '' 8761 } ); 8762 headerParent.css( 'padding-top', '' ); 8763 return; 8764 } 8765 8766 if ( isInView && ! isSticky ) { 8767 // Header is in the view but is not yet sticky. 8768 if ( headerTop >= scrollTop ) { 8769 // Header is fully visible. 8770 headerElement 8771 .addClass( 'is-sticky' ) 8772 .css( { 8773 top: parentContainer.css( 'top' ), 8774 width: headerParent.outerWidth() + 'px' 8775 } ); 8776 } 8777 } else if ( maybeSticky && ! isInView ) { 8778 // Header is out of the view. 8779 headerElement 8780 .addClass( 'is-in-view' ) 8781 .css( 'top', ( scrollTop - headerHeight ) + 'px' ); 8782 headerParent.css( 'padding-top', headerHeight + 'px' ); 8783 } 8784 }; 8785 }()); 8786 8787 // Previewed device bindings. (The api.previewedDevice property 8788 // is how this Value was first introduced, but since it has moved to api.state.) 8789 api.previewedDevice = api.state( 'previewedDevice' ); 8790 8791 // Set the default device. 8792 api.bind( 'ready', function() { 8793 _.find( api.settings.previewableDevices, function( value, key ) { 8794 if ( true === value['default'] ) { 8795 api.previewedDevice.set( key ); 8796 return true; 8797 } 8798 } ); 8799 } ); 8800 8801 // Set the toggled device. 8802 footerActions.find( '.devices button' ).on( 'click', function( event ) { 8803 api.previewedDevice.set( $( event.currentTarget ).data( 'device' ) ); 8804 }); 8805 8806 // Bind device changes. 8807 api.previewedDevice.bind( function( newDevice ) { 8808 var overlay = $( '.wp-full-overlay' ), 8809 devices = ''; 8810 8811 footerActions.find( '.devices button' ) 8812 .removeClass( 'active' ) 8813 .attr( 'aria-pressed', false ); 8814 8815 footerActions.find( '.devices .preview-' + newDevice ) 8816 .addClass( 'active' ) 8817 .attr( 'aria-pressed', true ); 8818 8819 $.each( api.settings.previewableDevices, function( device ) { 8820 devices += ' preview-' + device; 8821 } ); 8822 8823 overlay 8824 .removeClass( devices ) 8825 .addClass( 'preview-' + newDevice ); 8826 } ); 8827 8828 // Bind site title display to the corresponding field. 8829 if ( title.length ) { 8830 api( 'blogname', function( setting ) { 8831 var updateTitle = function() { 8832 var blogTitle = setting() || ''; 8833 title.text( blogTitle.toString().trim() || api.l10n.untitledBlogName ); 8834 }; 8835 setting.bind( updateTitle ); 8836 updateTitle(); 8837 } ); 8838 } 8839 8840 /* 8841 * Create a postMessage connection with a parent frame, 8842 * in case the Customizer frame was opened with the Customize loader. 8843 * 8844 * @see wp.customize.Loader 8845 */ 8846 parent = new api.Messenger({ 8847 url: api.settings.url.parent, 8848 channel: 'loader' 8849 }); 8850 8851 // Handle exiting of Customizer. 8852 (function() { 8853 var isInsideIframe = false; 8854 8855 function isCleanState() { 8856 var defaultChangesetStatus; 8857 8858 /* 8859 * Handle special case of previewing theme switch since some settings (for nav menus and widgets) 8860 * are pre-dirty and non-active themes can only ever be auto-drafts. 8861 */ 8862 if ( ! api.state( 'activated' ).get() ) { 8863 return 0 === api._latestRevision; 8864 } 8865 8866 // Dirty if the changeset status has been changed but not saved yet. 8867 defaultChangesetStatus = api.state( 'changesetStatus' ).get(); 8868 if ( '' === defaultChangesetStatus || 'auto-draft' === defaultChangesetStatus ) { 8869 defaultChangesetStatus = 'publish'; 8870 } 8871 if ( api.state( 'selectedChangesetStatus' ).get() !== defaultChangesetStatus ) { 8872 return false; 8873 } 8874 8875 // Dirty if scheduled but the changeset date hasn't been saved yet. 8876 if ( 'future' === api.state( 'selectedChangesetStatus' ).get() && api.state( 'selectedChangesetDate' ).get() !== api.state( 'changesetDate' ).get() ) { 8877 return false; 8878 } 8879 8880 return api.state( 'saved' ).get() && 'auto-draft' !== api.state( 'changesetStatus' ).get(); 8881 } 8882 8883 /* 8884 * If we receive a 'back' event, we're inside an iframe. 8885 * Send any clicks to the 'Return' link to the parent page. 8886 */ 8887 parent.bind( 'back', function() { 8888 isInsideIframe = true; 8889 }); 8890 8891 function startPromptingBeforeUnload() { 8892 api.unbind( 'change', startPromptingBeforeUnload ); 8893 api.state( 'selectedChangesetStatus' ).unbind( startPromptingBeforeUnload ); 8894 api.state( 'selectedChangesetDate' ).unbind( startPromptingBeforeUnload ); 8895 8896 // Prompt user with AYS dialog if leaving the Customizer with unsaved changes. 8897 $( window ).on( 'beforeunload.customize-confirm', function() { 8898 if ( ! isCleanState() && ! api.state( 'changesetLocked' ).get() ) { 8899 setTimeout( function() { 8900 overlay.removeClass( 'customize-loading' ); 8901 }, 1 ); 8902 return api.l10n.saveAlert; 8903 } 8904 }); 8905 } 8906 api.bind( 'change', startPromptingBeforeUnload ); 8907 api.state( 'selectedChangesetStatus' ).bind( startPromptingBeforeUnload ); 8908 api.state( 'selectedChangesetDate' ).bind( startPromptingBeforeUnload ); 8909 8910 function requestClose() { 8911 var clearedToClose = $.Deferred(), dismissAutoSave = false, dismissLock = false; 8912 8913 if ( isCleanState() ) { 8914 dismissLock = true; 8915 } else if ( confirm( api.l10n.saveAlert ) ) { 8916 8917 dismissLock = true; 8918 8919 // Mark all settings as clean to prevent another call to requestChangesetUpdate. 8920 api.each( function( setting ) { 8921 setting._dirty = false; 8922 }); 8923 $( document ).off( 'visibilitychange.wp-customize-changeset-update' ); 8924 $( window ).off( 'beforeunload.wp-customize-changeset-update' ); 8925 8926 closeBtn.css( 'cursor', 'progress' ); 8927 if ( '' !== api.state( 'changesetStatus' ).get() ) { 8928 dismissAutoSave = true; 8929 } 8930 } else { 8931 clearedToClose.reject(); 8932 } 8933 8934 if ( dismissLock || dismissAutoSave ) { 8935 wp.ajax.send( 'customize_dismiss_autosave_or_lock', { 8936 timeout: 500, // Don't wait too long. 8937 data: { 8938 wp_customize: 'on', 8939 customize_theme: api.settings.theme.stylesheet, 8940 customize_changeset_uuid: api.settings.changeset.uuid, 8941 nonce: api.settings.nonce.dismiss_autosave_or_lock, 8942 dismiss_autosave: dismissAutoSave, 8943 dismiss_lock: dismissLock 8944 } 8945 } ).always( function() { 8946 clearedToClose.resolve(); 8947 } ); 8948 } 8949 8950 return clearedToClose.promise(); 8951 } 8952 8953 parent.bind( 'confirm-close', function() { 8954 requestClose().done( function() { 8955 parent.send( 'confirmed-close', true ); 8956 } ).fail( function() { 8957 parent.send( 'confirmed-close', false ); 8958 } ); 8959 } ); 8960 8961 closeBtn.on( 'click.customize-controls-close', function( event ) { 8962 event.preventDefault(); 8963 if ( isInsideIframe ) { 8964 parent.send( 'close' ); // See confirm-close logic above. 8965 } else { 8966 requestClose().done( function() { 8967 $( window ).off( 'beforeunload.customize-confirm' ); 8968 window.location.href = closeBtn.prop( 'href' ); 8969 } ); 8970 } 8971 }); 8972 })(); 8973 8974 // Pass events through to the parent. 8975 $.each( [ 'saved', 'change' ], function ( i, event ) { 8976 api.bind( event, function() { 8977 parent.send( event ); 8978 }); 8979 } ); 8980 8981 // Pass titles to the parent. 8982 api.bind( 'title', function( newTitle ) { 8983 parent.send( 'title', newTitle ); 8984 }); 8985 8986 if ( api.settings.changeset.branching ) { 8987 parent.send( 'changeset-uuid', api.settings.changeset.uuid ); 8988 } 8989 8990 // Initialize the connection with the parent frame. 8991 parent.send( 'ready' ); 8992 8993 // Control visibility for default controls. 8994 $.each({ 8995 'background_image': { 8996 controls: [ 'background_preset', 'background_position', 'background_size', 'background_repeat', 'background_attachment' ], 8997 callback: function( to ) { return !! to; } 8998 }, 8999 'show_on_front': { 9000 controls: [ 'page_on_front', 'page_for_posts' ], 9001 callback: function( to ) { return 'page' === to; } 9002 }, 9003 'header_textcolor': { 9004 controls: [ 'header_textcolor' ], 9005 callback: function( to ) { return 'blank' !== to; } 9006 } 9007 }, function( settingId, o ) { 9008 api( settingId, function( setting ) { 9009 $.each( o.controls, function( i, controlId ) { 9010 api.control( controlId, function( control ) { 9011 var visibility = function( to ) { 9012 control.container.toggle( o.callback( to ) ); 9013 }; 9014 9015 visibility( setting.get() ); 9016 setting.bind( visibility ); 9017 }); 9018 }); 9019 }); 9020 }); 9021 9022 api.control( 'background_preset', function( control ) { 9023 var visibility, defaultValues, values, toggleVisibility, updateSettings, preset; 9024 9025 visibility = { // position, size, repeat, attachment. 9026 'default': [ false, false, false, false ], 9027 'fill': [ true, false, false, false ], 9028 'fit': [ true, false, true, false ], 9029 'repeat': [ true, false, false, true ], 9030 'custom': [ true, true, true, true ] 9031 }; 9032 9033 defaultValues = [ 9034 _wpCustomizeBackground.defaults['default-position-x'], 9035 _wpCustomizeBackground.defaults['default-position-y'], 9036 _wpCustomizeBackground.defaults['default-size'], 9037 _wpCustomizeBackground.defaults['default-repeat'], 9038 _wpCustomizeBackground.defaults['default-attachment'] 9039 ]; 9040 9041 values = { // position_x, position_y, size, repeat, attachment. 9042 'default': defaultValues, 9043 'fill': [ 'left', 'top', 'cover', 'no-repeat', 'fixed' ], 9044 'fit': [ 'left', 'top', 'contain', 'no-repeat', 'fixed' ], 9045 'repeat': [ 'left', 'top', 'auto', 'repeat', 'scroll' ] 9046 }; 9047 9048 // @todo These should actually toggle the active state, 9049 // but without the preview overriding the state in data.activeControls. 9050 toggleVisibility = function( preset ) { 9051 _.each( [ 'background_position', 'background_size', 'background_repeat', 'background_attachment' ], function( controlId, i ) { 9052 var control = api.control( controlId ); 9053 if ( control ) { 9054 control.container.toggle( visibility[ preset ][ i ] ); 9055 } 9056 } ); 9057 }; 9058 9059 updateSettings = function( preset ) { 9060 _.each( [ 'background_position_x', 'background_position_y', 'background_size', 'background_repeat', 'background_attachment' ], function( settingId, i ) { 9061 var setting = api( settingId ); 9062 if ( setting ) { 9063 setting.set( values[ preset ][ i ] ); 9064 } 9065 } ); 9066 }; 9067 9068 preset = control.setting.get(); 9069 toggleVisibility( preset ); 9070 9071 control.setting.bind( 'change', function( preset ) { 9072 toggleVisibility( preset ); 9073 if ( 'custom' !== preset ) { 9074 updateSettings( preset ); 9075 } 9076 } ); 9077 } ); 9078 9079 api.control( 'background_repeat', function( control ) { 9080 control.elements[0].unsync( api( 'background_repeat' ) ); 9081 9082 control.element = new api.Element( control.container.find( 'input' ) ); 9083 control.element.set( 'no-repeat' !== control.setting() ); 9084 9085 control.element.bind( function( to ) { 9086 control.setting.set( to ? 'repeat' : 'no-repeat' ); 9087 } ); 9088 9089 control.setting.bind( function( to ) { 9090 control.element.set( 'no-repeat' !== to ); 9091 } ); 9092 } ); 9093 9094 api.control( 'background_attachment', function( control ) { 9095 control.elements[0].unsync( api( 'background_attachment' ) ); 9096 9097 control.element = new api.Element( control.container.find( 'input' ) ); 9098 control.element.set( 'fixed' !== control.setting() ); 9099 9100 control.element.bind( function( to ) { 9101 control.setting.set( to ? 'scroll' : 'fixed' ); 9102 } ); 9103 9104 control.setting.bind( function( to ) { 9105 control.element.set( 'fixed' !== to ); 9106 } ); 9107 } ); 9108 9109 // Juggle the two controls that use header_textcolor. 9110 api.control( 'display_header_text', function( control ) { 9111 var last = ''; 9112 9113 control.elements[0].unsync( api( 'header_textcolor' ) ); 9114 9115 control.element = new api.Element( control.container.find('input') ); 9116 control.element.set( 'blank' !== control.setting() ); 9117 9118 control.element.bind( function( to ) { 9119 if ( ! to ) { 9120 last = api( 'header_textcolor' ).get(); 9121 } 9122 9123 control.setting.set( to ? last : 'blank' ); 9124 }); 9125 9126 control.setting.bind( function( to ) { 9127 control.element.set( 'blank' !== to ); 9128 }); 9129 }); 9130 9131 // Add behaviors to the static front page controls. 9132 api( 'show_on_front', 'page_on_front', 'page_for_posts', function( showOnFront, pageOnFront, pageForPosts ) { 9133 var handleChange = function() { 9134 var setting = this, pageOnFrontId, pageForPostsId, errorCode = 'show_on_front_page_collision'; 9135 pageOnFrontId = parseInt( pageOnFront(), 10 ); 9136 pageForPostsId = parseInt( pageForPosts(), 10 ); 9137 9138 if ( 'page' === showOnFront() ) { 9139 9140 // Change previewed URL to the homepage when changing the page_on_front. 9141 if ( setting === pageOnFront && pageOnFrontId > 0 ) { 9142 api.previewer.previewUrl.set( api.settings.url.home ); 9143 } 9144 9145 // Change the previewed URL to the selected page when changing the page_for_posts. 9146 if ( setting === pageForPosts && pageForPostsId > 0 ) { 9147 api.previewer.previewUrl.set( api.settings.url.home + '?page_id=' + pageForPostsId ); 9148 } 9149 } 9150 9151 // Toggle notification when the homepage and posts page are both set and the same. 9152 if ( 'page' === showOnFront() && pageOnFrontId && pageForPostsId && pageOnFrontId === pageForPostsId ) { 9153 showOnFront.notifications.add( new api.Notification( errorCode, { 9154 type: 'error', 9155 message: api.l10n.pageOnFrontError 9156 } ) ); 9157 } else { 9158 showOnFront.notifications.remove( errorCode ); 9159 } 9160 }; 9161 showOnFront.bind( handleChange ); 9162 pageOnFront.bind( handleChange ); 9163 pageForPosts.bind( handleChange ); 9164 handleChange.call( showOnFront, showOnFront() ); // Make sure initial notification is added after loading existing changeset. 9165 9166 // Move notifications container to the bottom. 9167 api.control( 'show_on_front', function( showOnFrontControl ) { 9168 showOnFrontControl.deferred.embedded.done( function() { 9169 showOnFrontControl.container.append( showOnFrontControl.getNotificationsContainerElement() ); 9170 }); 9171 }); 9172 }); 9173 9174 // Add code editor for Custom CSS. 9175 (function() { 9176 var sectionReady = $.Deferred(); 9177 9178 api.section( 'custom_css', function( section ) { 9179 section.deferred.embedded.done( function() { 9180 if ( section.expanded() ) { 9181 sectionReady.resolve( section ); 9182 } else { 9183 section.expanded.bind( function( isExpanded ) { 9184 if ( isExpanded ) { 9185 sectionReady.resolve( section ); 9186 } 9187 } ); 9188 } 9189 }); 9190 }); 9191 9192 // Set up the section description behaviors. 9193 sectionReady.done( function setupSectionDescription( section ) { 9194 var control = api.control( 'custom_css' ); 9195 9196 // Hide redundant label for visual users. 9197 control.container.find( '.customize-control-title:first' ).addClass( 'screen-reader-text' ); 9198 9199 // Close the section description when clicking the close button. 9200 section.container.find( '.section-description-buttons .section-description-close' ).on( 'click', function() { 9201 section.container.find( '.section-meta .customize-section-description:first' ) 9202 .removeClass( 'open' ) 9203 .slideUp(); 9204 9205 section.container.find( '.customize-help-toggle' ) 9206 .attr( 'aria-expanded', 'false' ) 9207 .focus(); // Avoid focus loss. 9208 }); 9209 9210 // Reveal help text if setting is empty. 9211 if ( control && ! control.setting.get() ) { 9212 section.container.find( '.section-meta .customize-section-description:first' ) 9213 .addClass( 'open' ) 9214 .show() 9215 .trigger( 'toggled' ); 9216 9217 section.container.find( '.customize-help-toggle' ).attr( 'aria-expanded', 'true' ); 9218 } 9219 }); 9220 })(); 9221 9222 // Toggle visibility of Header Video notice when active state change. 9223 api.control( 'header_video', function( headerVideoControl ) { 9224 headerVideoControl.deferred.embedded.done( function() { 9225 var toggleNotice = function() { 9226 var section = api.section( headerVideoControl.section() ), noticeCode = 'video_header_not_available'; 9227 if ( ! section ) { 9228 return; 9229 } 9230 if ( headerVideoControl.active.get() ) { 9231 section.notifications.remove( noticeCode ); 9232 } else { 9233 section.notifications.add( new api.Notification( noticeCode, { 9234 type: 'info', 9235 message: api.l10n.videoHeaderNotice 9236 } ) ); 9237 } 9238 }; 9239 toggleNotice(); 9240 headerVideoControl.active.bind( toggleNotice ); 9241 } ); 9242 } ); 9243 9244 // Update the setting validities. 9245 api.previewer.bind( 'selective-refresh-setting-validities', function handleSelectiveRefreshedSettingValidities( settingValidities ) { 9246 api._handleSettingValidities( { 9247 settingValidities: settingValidities, 9248 focusInvalidControl: false 9249 } ); 9250 } ); 9251 9252 // Focus on the control that is associated with the given setting. 9253 api.previewer.bind( 'focus-control-for-setting', function( settingId ) { 9254 var matchedControls = []; 9255 api.control.each( function( control ) { 9256 var settingIds = _.pluck( control.settings, 'id' ); 9257 if ( -1 !== _.indexOf( settingIds, settingId ) ) { 9258 matchedControls.push( control ); 9259 } 9260 } ); 9261 9262 // Focus on the matched control with the lowest priority (appearing higher). 9263 if ( matchedControls.length ) { 9264 matchedControls.sort( function( a, b ) { 9265 return a.priority() - b.priority(); 9266 } ); 9267 matchedControls[0].focus(); 9268 } 9269 } ); 9270 9271 // Refresh the preview when it requests. 9272 api.previewer.bind( 'refresh', function() { 9273 api.previewer.refresh(); 9274 }); 9275 9276 // Update the edit shortcut visibility state. 9277 api.state( 'paneVisible' ).bind( function( isPaneVisible ) { 9278 var isMobileScreen; 9279 if ( window.matchMedia ) { 9280 isMobileScreen = window.matchMedia( 'screen and ( max-width: 640px )' ).matches; 9281 } else { 9282 isMobileScreen = $( window ).width() <= 640; 9283 } 9284 api.state( 'editShortcutVisibility' ).set( isPaneVisible || isMobileScreen ? 'visible' : 'hidden' ); 9285 } ); 9286 if ( window.matchMedia ) { 9287 window.matchMedia( 'screen and ( max-width: 640px )' ).addListener( function() { 9288 var state = api.state( 'paneVisible' ); 9289 state.callbacks.fireWith( state, [ state.get(), state.get() ] ); 9290 } ); 9291 } 9292 api.previewer.bind( 'edit-shortcut-visibility', function( visibility ) { 9293 api.state( 'editShortcutVisibility' ).set( visibility ); 9294 } ); 9295 api.state( 'editShortcutVisibility' ).bind( function( visibility ) { 9296 api.previewer.send( 'edit-shortcut-visibility', visibility ); 9297 } ); 9298 9299 // Autosave changeset. 9300 function startAutosaving() { 9301 var timeoutId, updateChangesetWithReschedule, scheduleChangesetUpdate, updatePending = false; 9302 9303 api.unbind( 'change', startAutosaving ); // Ensure startAutosaving only fires once. 9304 9305 function onChangeSaved( isSaved ) { 9306 if ( ! isSaved && ! api.settings.changeset.autosaved ) { 9307 api.settings.changeset.autosaved = true; // Once a change is made then autosaving kicks in. 9308 api.previewer.send( 'autosaving' ); 9309 } 9310 } 9311 api.state( 'saved' ).bind( onChangeSaved ); 9312 onChangeSaved( api.state( 'saved' ).get() ); 9313 9314 /** 9315 * Request changeset update and then re-schedule the next changeset update time. 9316 * 9317 * @since 4.7.0 9318 * @private 9319 */ 9320 updateChangesetWithReschedule = function() { 9321 if ( ! updatePending ) { 9322 updatePending = true; 9323 api.requestChangesetUpdate( {}, { autosave: true } ).always( function() { 9324 updatePending = false; 9325 } ); 9326 } 9327 scheduleChangesetUpdate(); 9328 }; 9329 9330 /** 9331 * Schedule changeset update. 9332 * 9333 * @since 4.7.0 9334 * @private 9335 */ 9336 scheduleChangesetUpdate = function() { 9337 clearTimeout( timeoutId ); 9338 timeoutId = setTimeout( function() { 9339 updateChangesetWithReschedule(); 9340 }, api.settings.timeouts.changesetAutoSave ); 9341 }; 9342 9343 // Start auto-save interval for updating changeset. 9344 scheduleChangesetUpdate(); 9345 9346 // Save changeset when focus removed from window. 9347 $( document ).on( 'visibilitychange.wp-customize-changeset-update', function() { 9348 if ( document.hidden ) { 9349 updateChangesetWithReschedule(); 9350 } 9351 } ); 9352 9353 // Save changeset before unloading window. 9354 $( window ).on( 'beforeunload.wp-customize-changeset-update', function() { 9355 updateChangesetWithReschedule(); 9356 } ); 9357 } 9358 api.bind( 'change', startAutosaving ); 9359 9360 // Make sure TinyMCE dialogs appear above Customizer UI. 9361 $( document ).one( 'tinymce-editor-setup', function() { 9362 if ( window.tinymce.ui.FloatPanel && ( ! window.tinymce.ui.FloatPanel.zIndex || window.tinymce.ui.FloatPanel.zIndex < 500001 ) ) { 9363 window.tinymce.ui.FloatPanel.zIndex = 500001; 9364 } 9365 } ); 9366 9367 body.addClass( 'ready' ); 9368 api.trigger( 'ready' ); 9369 }); 9370 9371 })( wp, jQuery );
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated : Tue Sep 9 08:20:04 2025 | Cross-referenced by PHPXref |