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