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