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