X-Git-Url: https://scripts.mit.edu/gitweb/autoinstalls/wordpress.git/blobdiff_plain/03f2fa83c13c1b532284205fa7efcab9b8b2c41f..0f74cdeda4c069bfbb9c4131ef1352f55b6f8499:/wp-admin/js/customize-controls.js diff --git a/wp-admin/js/customize-controls.js b/wp-admin/js/customize-controls.js index e78d2c96..e6270ee0 100644 --- a/wp-admin/js/customize-controls.js +++ b/wp-admin/js/customize-controls.js @@ -1,6 +1,6 @@ /* global _wpCustomizeHeader, _wpCustomizeBackground, _wpMediaViewsL10n, MediaElementPlayer */ (function( exports, $ ){ - var Container, focus, api = wp.customize; + var Container, focus, normalizedTransitionendEventName, api = wp.customize; /** * A Customizer Setting. @@ -22,33 +22,234 @@ */ api.Setting = api.Value.extend({ initialize: function( id, value, options ) { - api.Value.prototype.initialize.call( this, value, options ); + var setting = this; + api.Value.prototype.initialize.call( setting, value, options ); - this.id = id; - this.transport = this.transport || 'refresh'; - this._dirty = options.dirty || false; + setting.id = id; + setting.transport = setting.transport || 'refresh'; + setting._dirty = options.dirty || false; + setting.notifications = new api.Values({ defaultConstructor: api.Notification }); // Whenever the setting's value changes, refresh the preview. - this.bind( this.preview ); + setting.bind( setting.preview ); }, /** * Refresh the preview, respective of the setting's refresh policy. + * + * If the preview hasn't sent a keep-alive message and is likely + * disconnected by having navigated to a non-allowed URL, then the + * refresh transport will be forced when postMessage is the transport. + * Note that postMessage does not throw an error when the recipient window + * fails to match the origin window, so using try/catch around the + * previewer.send() call to then fallback to refresh will not work. + * + * @since 3.4.0 + * @access public + * + * @returns {void} */ preview: function() { - switch ( this.transport ) { - case 'refresh': - return this.previewer.refresh(); - case 'postMessage': - return this.previewer.send( 'setting', [ this.id, this() ] ); + var setting = this, transport; + transport = setting.transport; + + if ( 'postMessage' === transport && ! api.state( 'previewerAlive' ).get() ) { + transport = 'refresh'; } + + if ( 'postMessage' === transport ) { + setting.previewer.send( 'setting', [ setting.id, setting() ] ); + } else if ( 'refresh' === transport ) { + setting.previewer.refresh(); + } + }, + + /** + * Find controls associated with this setting. + * + * @since 4.6.0 + * @returns {wp.customize.Control[]} Controls associated with setting. + */ + findControls: function() { + var setting = this, controls = []; + api.control.each( function( control ) { + _.each( control.settings, function( controlSetting ) { + if ( controlSetting.id === setting.id ) { + controls.push( control ); + } + } ); + } ); + return controls; } }); /** - * Utility function namespace + * Current change count. + * + * @since 4.7.0 + * @type {number} + * @protected + */ + api._latestRevision = 0; + + /** + * Last revision that was saved. + * + * @since 4.7.0 + * @type {number} + * @protected + */ + api._lastSavedRevision = 0; + + /** + * Latest revisions associated with the updated setting. + * + * @since 4.7.0 + * @type {object} + * @protected + */ + api._latestSettingRevisions = {}; + + /* + * Keep track of the revision associated with each updated setting so that + * requestChangesetUpdate knows which dirty settings to include. Also, once + * ready is triggered and all initial settings have been added, increment + * revision for each newly-created initially-dirty setting so that it will + * also be included in changeset update requests. + */ + api.bind( 'change', function incrementChangedSettingRevision( setting ) { + api._latestRevision += 1; + api._latestSettingRevisions[ setting.id ] = api._latestRevision; + } ); + api.bind( 'ready', function() { + api.bind( 'add', function incrementCreatedSettingRevision( setting ) { + if ( setting._dirty ) { + api._latestRevision += 1; + api._latestSettingRevisions[ setting.id ] = api._latestRevision; + } + } ); + } ); + + /** + * Get the dirty setting values. + * + * @since 4.7.0 + * @access public + * + * @param {object} [options] Options. + * @param {boolean} [options.unsaved=false] Whether only values not saved yet into a changeset will be returned (differential changes). + * @returns {object} Dirty setting values. + */ + api.dirtyValues = function dirtyValues( options ) { + var values = {}; + api.each( function( setting ) { + var settingRevision; + + if ( ! setting._dirty ) { + return; + } + + settingRevision = api._latestSettingRevisions[ setting.id ]; + + // Skip including settings that have already been included in the changeset, if only requesting unsaved. + if ( api.state( 'changesetStatus' ).get() && ( options && options.unsaved ) && ( _.isUndefined( settingRevision ) || settingRevision <= api._lastSavedRevision ) ) { + return; + } + + values[ setting.id ] = setting.get(); + } ); + return values; + }; + + /** + * Request updates to the changeset. + * + * @since 4.7.0 + * @access public + * + * @param {object} [changes] Mapping of setting IDs to setting params each normally including a value property, or mapping to null. + * If not provided, then the changes will still be obtained from unsaved dirty settings. + * @returns {jQuery.Promise} Promise resolving with the response data. */ - api.utils = {}; + api.requestChangesetUpdate = function requestChangesetUpdate( changes ) { + var deferred, request, submittedChanges = {}, data; + deferred = new $.Deferred(); + + if ( changes ) { + _.extend( submittedChanges, changes ); + } + + // Ensure all revised settings (changes pending save) are also included, but not if marked for deletion in changes. + _.each( api.dirtyValues( { unsaved: true } ), function( dirtyValue, settingId ) { + if ( ! changes || null !== changes[ settingId ] ) { + submittedChanges[ settingId ] = _.extend( + {}, + submittedChanges[ settingId ] || {}, + { value: dirtyValue } + ); + } + } ); + + // Short-circuit when there are no pending changes. + if ( _.isEmpty( submittedChanges ) ) { + deferred.resolve( {} ); + return deferred.promise(); + } + + // Make sure that publishing a changeset waits for all changeset update requests to complete. + api.state( 'processing' ).set( api.state( 'processing' ).get() + 1 ); + deferred.always( function() { + api.state( 'processing' ).set( api.state( 'processing' ).get() - 1 ); + } ); + + // Allow plugins to attach additional params to the settings. + api.trigger( 'changeset-save', submittedChanges ); + + // Ensure that if any plugins add data to save requests by extending query() that they get included here. + data = api.previewer.query( { excludeCustomizedSaved: true } ); + delete data.customized; // Being sent in customize_changeset_data instead. + _.extend( data, { + nonce: api.settings.nonce.save, + customize_theme: api.settings.theme.stylesheet, + customize_changeset_data: JSON.stringify( submittedChanges ) + } ); + + request = wp.ajax.post( 'customize_save', data ); + + request.done( function requestChangesetUpdateDone( data ) { + var savedChangesetValues = {}; + + // Ensure that all settings updated subsequently will be included in the next changeset update request. + api._lastSavedRevision = Math.max( api._latestRevision, api._lastSavedRevision ); + + api.state( 'changesetStatus' ).set( data.changeset_status ); + deferred.resolve( data ); + api.trigger( 'changeset-saved', data ); + + if ( data.setting_validities ) { + _.each( data.setting_validities, function( validity, settingId ) { + if ( true === validity && _.isObject( submittedChanges[ settingId ] ) && ! _.isUndefined( submittedChanges[ settingId ].value ) ) { + savedChangesetValues[ settingId ] = submittedChanges[ settingId ].value; + } + } ); + } + + api.previewer.send( 'changeset-saved', _.extend( {}, data, { saved_changeset_values: savedChangesetValues } ) ); + } ); + request.fail( function requestChangesetUpdateFail( data ) { + deferred.reject( data ); + api.trigger( 'changeset-error', data ); + } ); + request.always( function( data ) { + if ( data.setting_validities ) { + api._handleSettingValidities( { + settingValidities: data.setting_validities + } ); + } + } ); + + return deferred.promise(); + }; /** * Watch all changes to Value properties, and bubble changes to parent Values instance @@ -74,24 +275,26 @@ * @since 4.1.0 * * @param {Object} [params] - * @param {Callback} [params.completeCallback] + * @param {Function} [params.completeCallback] */ focus = function ( params ) { - var construct, completeCallback, focus; + var construct, completeCallback, focus, focusElement; construct = this; params = params || {}; focus = function () { var focusContainer; - if ( construct.extended( api.Panel ) && construct.expanded && construct.expanded() ) { - focusContainer = construct.container.find( 'ul.control-panel-content' ); - } else if ( construct.extended( api.Section ) && construct.expanded && construct.expanded() ) { - focusContainer = construct.container.find( 'ul.accordion-section-content' ); + if ( ( construct.extended( api.Panel ) || construct.extended( api.Section ) ) && construct.expanded && construct.expanded() ) { + focusContainer = construct.contentContainer; } else { focusContainer = construct.container; } - // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583 - focusContainer.find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' ).first().focus(); + focusElement = focusContainer.find( '.control-focus:first' ); + if ( 0 === focusElement.length ) { + // Note that we can't use :focusable due to a jQuery UI issue. See: https://github.com/jquery/jquery-ui/pull/1583 + focusElement = focusContainer.find( 'input, select, textarea, button, object, a[href], [tabindex]' ).filter( ':visible' ).first(); + } + focusElement.focus(); }; if ( params.completeCallback ) { completeCallback = params.completeCallback; @@ -102,6 +305,8 @@ } else { params.completeCallback = focus; } + + api.state( 'paneVisible' ).set( true ); if ( construct.expand ) { construct.expand( params ); } else { @@ -162,6 +367,32 @@ return equal; }; + /** + * Return browser supported `transitionend` event name. + * + * @since 4.7.0 + * + * @returns {string|null} Normalized `transitionend` event name or null if CSS transitions are not supported. + */ + normalizedTransitionendEventName = (function () { + var el, transitions, prop; + el = document.createElement( 'div' ); + transitions = { + 'transition' : 'transitionend', + 'OTransition' : 'oTransitionEnd', + 'MozTransition' : 'transitionend', + 'WebkitTransition': 'webkitTransitionEnd' + }; + prop = _.find( _.keys( transitions ), function( prop ) { + return ! _.isUndefined( el.style[ prop ] ); + } ); + if ( prop ) { + return transitions[ prop ]; + } else { + return null; + } + })(); + /** * Base class for Panel and Section. * @@ -213,6 +444,9 @@ if ( 0 === container.container.length ) { container.container = $( container.getContainer() ); } + container.headContainer = container.container; + container.contentContainer = container.getContent(); + container.container = container.container.add( container.contentContainer ); container.deferred = { embedded: new $.Deferred() @@ -300,7 +534,10 @@ * @param {Object} args.completeCallback */ onChangeActive: function( active, args ) { - var duration, construct = this, expandedOtherPanel; + var construct = this, + headContainer = construct.headContainer, + duration, expandedOtherPanel; + if ( args.unchanged ) { if ( args.completeCallback ) { args.completeCallback(); @@ -327,31 +564,26 @@ } } - if ( ! $.contains( document, construct.container[0] ) ) { + if ( ! $.contains( document, headContainer ) ) { // jQuery.fn.slideUp is not hiding an element if it is not in the DOM - construct.container.toggle( active ); + headContainer.toggle( active ); if ( args.completeCallback ) { args.completeCallback(); } } else if ( active ) { - construct.container.stop( true, true ).slideDown( duration, args.completeCallback ); + headContainer.stop( true, true ).slideDown( duration, args.completeCallback ); } else { if ( construct.expanded() ) { construct.collapse({ duration: duration, completeCallback: function() { - construct.container.stop( true, true ).slideUp( duration, args.completeCallback ); + headContainer.stop( true, true ).slideUp( duration, args.completeCallback ); } }); } else { - construct.container.stop( true, true ).slideUp( duration, args.completeCallback ); + headContainer.stop( true, true ).slideUp( duration, args.completeCallback ); } } - - // Recalculate the margin-top immediately, not waiting for debounced reflow, to prevent momentary (100ms) vertical jiggle. - if ( expandedOtherPanel ) { - expandedOtherPanel._recalculateTopMargin(); - } }, /** @@ -418,6 +650,7 @@ return false; } + api.state( 'paneVisible' ).set( true ); params.completeCallback = function() { if ( previousCompleteCallback ) { previousCompleteCallback.apply( instance, arguments ); @@ -456,6 +689,66 @@ return this._toggleExpanded( false, params ); }, + /** + * Animate container state change if transitions are supported by the browser. + * + * @since 4.7.0 + * @private + * + * @param {function} completeCallback Function to be called after transition is completed. + * @returns {void} + */ + _animateChangeExpanded: function( completeCallback ) { + // Return if CSS transitions are not supported. + if ( ! normalizedTransitionendEventName ) { + if ( completeCallback ) { + completeCallback(); + } + return; + } + + var construct = this, + content = construct.contentContainer, + overlay = content.closest( '.wp-full-overlay' ), + elements, transitionEndCallback; + + // Determine set of elements that are affected by the animation. + elements = overlay.add( content ); + if ( _.isUndefined( construct.panel ) || '' === construct.panel() ) { + elements = elements.add( '#customize-info, .customize-pane-parent' ); + } + + // Handle `transitionEnd` event. + transitionEndCallback = function( e ) { + if ( 2 !== e.eventPhase || ! $( e.target ).is( content ) ) { + return; + } + content.off( normalizedTransitionendEventName, transitionEndCallback ); + elements.removeClass( 'busy' ); + if ( completeCallback ) { + completeCallback(); + } + }; + content.on( normalizedTransitionendEventName, transitionEndCallback ); + elements.addClass( 'busy' ); + + // Prevent screen flicker when pane has been scrolled before expanding. + _.defer( function() { + var container = content.closest( '.wp-full-overlay-sidebar-content' ), + currentScrollTop = container.scrollTop(), + previousScrollTop = content.data( 'previous-scrollTop' ) || 0, + expanded = construct.expanded(); + + if ( expanded && 0 < currentScrollTop ) { + content.css( 'top', currentScrollTop + 'px' ); + content.data( 'previous-scrollTop', currentScrollTop ); + } else if ( ! expanded && 0 < currentScrollTop + previousScrollTop ) { + content.css( 'top', previousScrollTop - currentScrollTop + 'px' ); + container.scrollTop( previousScrollTop ); + } + } ); + }, + /** * Bring the container into view and then expand this and bring it into view * @param {Object} [params] @@ -481,6 +774,40 @@ } return '
  • '; + }, + + /** + * Find content element which is displayed when the section is expanded. + * + * After a construct is initialized, the return value will be available via the `contentContainer` property. + * By default the element will be related it to the parent container with `aria-owns` and detached. + * Custom panels and sections (such as the `NewMenuSection`) that do not have a sliding pane should + * just return the content element without needing to add the `aria-owns` element or detach it from + * the container. Such non-sliding pane custom sections also need to override the `onChangeExpanded` + * method to handle animating the panel/section into and out of view. + * + * @since 4.7.0 + * @access public + * + * @returns {jQuery} Detached content element. + */ + getContent: function() { + var construct = this, + container = construct.container, + content = container.find( '.accordion-section-content, .control-panel-content' ).first(), + contentId = 'sub-' + container.attr( 'id' ), + ownedElements = contentId, + alreadyOwnedElements = container.attr( 'aria-owns' ); + + if ( alreadyOwnedElements ) { + ownedElements = ownedElements + ' ' + alreadyOwnedElements; + } + container.attr( 'aria-owns', ownedElements ); + + return content.detach().attr( { + 'id': contentId, + 'class': 'customize-pane-child ' + content.attr( 'class' ) + ' ' + container.attr( 'class' ) + } ); } }); @@ -526,7 +853,7 @@ section.id = id; section.panel = new api.Value(); section.panel.bind( function ( id ) { - $( section.container ).toggleClass( 'control-subsection', !! id ); + $( section.headContainer ).toggleClass( 'control-subsection', !! id ); }); section.panel.set( section.params.panel || '' ); api.utils.bubbleChildValueChanges( section, [ 'panel' ] ); @@ -543,7 +870,9 @@ * @since 4.1.0 */ embed: function () { - var section = this, inject; + var inject, + section = this, + container = $( '#customize-theme-controls' ); // Watch for changes to the panel state inject = function ( panelId ) { @@ -553,31 +882,30 @@ api.panel( panelId, function ( panel ) { // The panel has been registered, wait for it to become ready/initialized panel.deferred.embedded.done( function () { - parentContainer = panel.container.find( 'ul:first' ); - if ( ! section.container.parent().is( parentContainer ) ) { - parentContainer.append( section.container ); + parentContainer = panel.contentContainer; + if ( ! section.headContainer.parent().is( parentContainer ) ) { + parentContainer.append( section.headContainer ); + } + if ( ! section.contentContainer.parent().is( section.headContainer ) ) { + container.append( section.contentContainer ); } section.deferred.embedded.resolve(); }); } ); } else { // There is no panel, so embed the section in the root of the customizer - parentContainer = $( '#customize-theme-controls' ).children( 'ul' ); // @todo This should be defined elsewhere, and to be configurable - if ( ! section.container.parent().is( parentContainer ) ) { - parentContainer.append( section.container ); + parentContainer = $( '.customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable + if ( ! section.headContainer.parent().is( parentContainer ) ) { + parentContainer.append( section.headContainer ); + } + if ( ! section.contentContainer.parent().is( section.headContainer ) ) { + container.append( section.contentContainer ); } section.deferred.embedded.resolve(); } }; section.panel.bind( inject ); inject( section.panel.get() ); // Since a section may never get a panel, assume that it won't ever get one - - section.deferred.embedded.done(function() { - // Fix the top margin after reflow. - api.bind( 'pane-contents-reflowed', _.debounce( function() { - section._recalculateTopMargin(); - }, 100 ) ); - }); }, /** @@ -586,7 +914,11 @@ * @since 4.1.0 */ attachEvents: function () { - var section = this; + var meta, content, section = this; + + if ( section.container.hasClass( 'cannot-expand' ) ) { + return; + } // Expand/Collapse accordion sections on click. section.container.find( '.accordion-section-title, .customize-section-back' ).on( 'click keydown', function( event ) { @@ -601,6 +933,21 @@ section.expand(); } }); + + // This is very similar to what is found for api.Panel.attachEvents(). + section.container.find( '.customize-section-title .customize-help-toggle' ).on( 'click', function() { + + meta = section.container.find( '.section-meta' ); + if ( meta.hasClass( 'cannot-expand' ) ) { + return; + } + content = meta.find( '.customize-section-description:first' ); + content.toggleClass( 'open' ); + content.slideToggle(); + content.attr( 'aria-expanded', function ( i, attr ) { + return 'true' === attr ? 'false' : 'true'; + }); + }); }, /** @@ -643,50 +990,36 @@ */ onChangeExpanded: function ( expanded, args ) { var section = this, - container = section.container.closest( '.wp-full-overlay-sidebar-content' ), - content = section.container.find( '.accordion-section-content' ), - overlay = section.container.closest( '.wp-full-overlay' ), - backBtn = section.container.find( '.customize-section-back' ), - sectionTitle = section.container.find( '.accordion-section-title' ).first(), - headerActionsHeight = $( '#customize-header-actions' ).height(), - resizeContentHeight, expand, position, scroll; + container = section.headContainer.closest( '.wp-full-overlay-sidebar-content' ), + content = section.contentContainer, + overlay = section.headContainer.closest( '.wp-full-overlay' ), + backBtn = content.find( '.customize-section-back' ), + sectionTitle = section.headContainer.find( '.accordion-section-title' ).first(), + expand; - if ( expanded && ! section.container.hasClass( 'open' ) ) { + if ( expanded && ! content.hasClass( 'open' ) ) { if ( args.unchanged ) { expand = args.completeCallback; } else { - container.scrollTop( 0 ); - resizeContentHeight = function() { - var matchMedia, offset; - matchMedia = window.matchMedia || window.msMatchMedia; - offset = 90; // 45px for customize header actions + 45px for footer actions. - - // No footer on small screens. - if ( matchMedia && matchMedia( '(max-width: 640px)' ).matches ) { - offset = 45; - } - content.css( 'height', ( window.innerHeight - offset ) ); - }; - expand = function() { - section.container.addClass( 'open' ); - overlay.addClass( 'section-open' ); - position = content.offset().top; - scroll = container.scrollTop(); - content.css( 'margin-top', ( headerActionsHeight - position - scroll ) ); - resizeContentHeight(); - sectionTitle.attr( 'tabindex', '-1' ); - backBtn.attr( 'tabindex', '0' ); - backBtn.focus(); - if ( args.completeCallback ) { - args.completeCallback(); - } + expand = $.proxy( function() { + section._animateChangeExpanded( function() { + sectionTitle.attr( 'tabindex', '-1' ); + backBtn.attr( 'tabindex', '0' ); - // Fix the height after browser resize. - $( window ).on( 'resize.customizer-section', _.debounce( resizeContentHeight, 100 ) ); + backBtn.focus(); + content.css( 'top', '' ); + container.scrollTop( 0 ); - section._recalculateTopMargin(); - }; + if ( args.completeCallback ) { + args.completeCallback(); + } + } ); + + content.addClass( 'open' ); + overlay.addClass( 'section-open' ); + api.state( 'expandedSection' ).set( section ); + }, this ); } if ( ! args.allowMultiple ) { @@ -709,42 +1042,30 @@ expand(); } - } else if ( ! expanded && section.container.hasClass( 'open' ) ) { - section.container.removeClass( 'open' ); + } else if ( ! expanded && content.hasClass( 'open' ) ) { + section._animateChangeExpanded( function() { + backBtn.attr( 'tabindex', '-1' ); + sectionTitle.attr( 'tabindex', '0' ); + + sectionTitle.focus(); + content.css( 'top', '' ); + + if ( args.completeCallback ) { + args.completeCallback(); + } + } ); + + content.removeClass( 'open' ); overlay.removeClass( 'section-open' ); - content.css( 'margin-top', '' ); - container.scrollTop( 0 ); - backBtn.attr( 'tabindex', '-1' ); - sectionTitle.attr( 'tabindex', '0' ); - sectionTitle.focus(); - if ( args.completeCallback ) { - args.completeCallback(); + if ( section === api.state( 'expandedSection' ).get() ) { + api.state( 'expandedSection' ).set( false ); } - $( window ).off( 'resize.customizer-section' ); + } else { if ( args.completeCallback ) { args.completeCallback(); } } - }, - - /** - * Recalculate the top margin. - * - * @since 4.4.0 - * @private - */ - _recalculateTopMargin: function() { - var section = this, content, offset, headerActionsHeight; - content = section.container.find( '.accordion-section-content' ); - if ( 0 === content.length ) { - return; - } - headerActionsHeight = $( '#customize-header-actions' ).height(); - offset = ( content.offset().top - headerActionsHeight ); - if ( 0 < offset ) { - content.css( 'margin-top', ( parseInt( content.css( 'margin-top' ), 10 ) - offset ) ); - } } }); @@ -782,7 +1103,7 @@ section.template = wp.template( 'customize-themes-details-view' ); // Bind global keyboard events. - $( 'body' ).on( 'keyup', function( event ) { + section.container.on( 'keydown', function( event ) { if ( ! section.overlay.find( '.theme-wrap' ).is( ':visible' ) ) { return; } @@ -800,6 +1121,7 @@ // Pressing the escape key fires a theme:collapse event if ( 27 === event.keyCode ) { section.closeDetails(); + event.stopPropagation(); // Prevent section from being collapsed. } }); @@ -922,18 +1244,14 @@ } // Note: there is a second argument 'args' passed - var position, scroll, - panel = this, - section = panel.container.closest( '.accordion-section' ), + var panel = this, + section = panel.contentContainer, overlay = section.closest( '.wp-full-overlay' ), container = section.closest( '.wp-full-overlay-sidebar-content' ), - siblings = container.find( '.open' ), customizeBtn = section.find( '.customize-theme' ), - changeBtn = section.find( '.change-theme' ), - content = section.find( '.control-panel-content' ); - - if ( expanded ) { + changeBtn = panel.headContainer.find( '.change-theme' ); + if ( expanded && ! section.hasClass( 'current-panel' ) ) { // Collapse any sibling sections/panels api.section.each( function ( otherSection ) { if ( otherSection !== panel ) { @@ -944,45 +1262,41 @@ otherPanel.collapse( { duration: 0 } ); }); - content.show( 0, function() { - position = content.offset().top; - scroll = container.scrollTop(); - content.css( 'margin-top', ( $( '#customize-header-actions' ).height() - position - scroll ) ); - section.addClass( 'current-panel' ); - overlay.addClass( 'in-themes-panel' ); + panel._animateChangeExpanded( function() { + changeBtn.attr( 'tabindex', '-1' ); + customizeBtn.attr( 'tabindex', '0' ); + + customizeBtn.focus(); + section.css( 'top', '' ); container.scrollTop( 0 ); - _.delay( panel.renderScreenshots, 10 ); // Wait for the controls - panel.$customizeSidebar.on( 'scroll.customize-themes-section', _.throttle( panel.renderScreenshots, 300 ) ); + if ( args.completeCallback ) { args.completeCallback(); } } ); - customizeBtn.focus(); - } else { - siblings.removeClass( 'open' ); - section.removeClass( 'current-panel' ); - overlay.removeClass( 'in-themes-panel' ); - panel.$customizeSidebar.off( 'scroll.customize-themes-section' ); - content.delay( 180 ).hide( 0, function() { - content.css( 'margin-top', 'inherit' ); // Reset + + overlay.addClass( 'in-themes-panel' ); + section.addClass( 'current-panel' ); + _.delay( panel.renderScreenshots, 10 ); // Wait for the controls + panel.$customizeSidebar.on( 'scroll.customize-themes-section', _.throttle( panel.renderScreenshots, 300 ) ); + + } else if ( ! expanded && section.hasClass( 'current-panel' ) ) { + panel._animateChangeExpanded( function() { + changeBtn.attr( 'tabindex', '0' ); + customizeBtn.attr( 'tabindex', '-1' ); + + changeBtn.focus(); + section.css( 'top', '' ); + if ( args.completeCallback ) { args.completeCallback(); } } ); - customizeBtn.attr( 'tabindex', '0' ); - changeBtn.focus(); - container.scrollTop( 0 ); - } - }, - /** - * Recalculate the top margin. - * - * @since 4.4.0 - * @private - */ - _recalculateTopMargin: function() { - api.Panel.prototype._recalculateTopMargin.call( this ); + overlay.removeClass( 'in-themes-panel' ); + section.removeClass( 'current-panel' ); + panel.$customizeSidebar.off( 'scroll.customize-themes-section' ); + } }, /** @@ -1111,6 +1425,60 @@ } }, + /** + * Load theme preview. + * + * @since 4.7.0 + * @access public + * + * @param {string} themeId Theme ID. + * @returns {jQuery.promise} Promise. + */ + loadThemePreview: function( themeId ) { + var deferred = $.Deferred(), onceProcessingComplete, overlay, urlParser; + + urlParser = document.createElement( 'a' ); + urlParser.href = location.href; + urlParser.search = $.param( _.extend( + api.utils.parseQueryString( urlParser.search.substr( 1 ) ), + { + theme: themeId, + changeset_uuid: api.settings.changeset.uuid + } + ) ); + + overlay = $( '.wp-full-overlay' ); + overlay.addClass( 'customize-loading' ); + + onceProcessingComplete = function() { + var request; + if ( api.state( 'processing' ).get() > 0 ) { + return; + } + + api.state( 'processing' ).unbind( onceProcessingComplete ); + + request = api.requestChangesetUpdate(); + request.done( function() { + $( window ).off( 'beforeunload.customize-confirm' ); + top.location.href = urlParser.href; + deferred.resolve(); + } ); + request.fail( function() { + overlay.removeClass( 'customize-loading' ); + deferred.reject(); + } ); + }; + + if ( 0 === api.state( 'processing' ).get() ) { + onceProcessingComplete(); + } else { + api.state( 'processing' ).bind( onceProcessingComplete ); + } + + return deferred.promise(); + }, + /** * Render & show the theme details for a given theme model. * @@ -1119,7 +1487,7 @@ * @param {Object} theme */ showDetails: function ( theme, callback ) { - var section = this; + var section = this, link; callback = callback || function(){}; section.currentTheme = theme.id; section.overlay.html( section.template( theme ) ) @@ -1128,6 +1496,22 @@ $( 'body' ).addClass( 'modal-open' ); section.containFocus( section.overlay ); section.updateLimits(); + + link = section.overlay.find( '.inactive-theme > a' ); + + link.on( 'click', function( event ) { + event.preventDefault(); + + // Short-circuit if request is currently being made. + if ( link.hasClass( 'disabled' ) ) { + return; + } + link.addClass( 'disabled' ); + + section.loadThemePreview( theme.id ).fail( function() { + link.removeClass( 'disabled' ); + } ); + } ); callback(); }, @@ -1211,17 +1595,17 @@ */ embed: function () { var panel = this, - parentContainer = $( '#customize-theme-controls > ul' ); // @todo This should be defined elsewhere, and to be configurable + container = $( '#customize-theme-controls' ), + parentContainer = $( '.customize-pane-parent' ); // @todo This should be defined elsewhere, and to be configurable - if ( ! panel.container.parent().is( parentContainer ) ) { - parentContainer.append( panel.container ); + if ( ! panel.headContainer.parent().is( parentContainer ) ) { + parentContainer.append( panel.headContainer ); + } + if ( ! panel.contentContainer.parent().is( panel.headContainer ) ) { + container.append( panel.contentContainer ); panel.renderContent(); } - api.bind( 'pane-contents-reflowed', _.debounce( function() { - panel._recalculateTopMargin(); - }, 100 ) ); - panel.deferred.embedded.resolve(); }, @@ -1232,7 +1616,7 @@ var meta, panel = this; // Expand/Collapse accordion sections on click. - panel.container.find( '.accordion-section-title' ).on( 'click keydown', function( event ) { + panel.headContainer.find( '.accordion-section-title' ).on( 'click keydown', function( event ) { if ( api.utils.isKeydownButNotEnterEvent( event ) ) { return; } @@ -1263,7 +1647,6 @@ } event.preventDefault(); // Keep this AFTER the key filter above - meta = panel.container.find( '.panel-meta' ); if ( meta.hasClass( 'cannot-expand' ) ) { return; } @@ -1333,20 +1716,14 @@ } // Note: there is a second argument 'args' passed - var position, scroll, - panel = this, - accordionSection = panel.container.closest( '.accordion-section' ), + var panel = this, + accordionSection = panel.contentContainer, overlay = accordionSection.closest( '.wp-full-overlay' ), container = accordionSection.closest( '.wp-full-overlay-sidebar-content' ), - siblings = container.find( '.open' ), - topPanel = overlay.find( '#customize-theme-controls > ul > .accordion-section > .accordion-section-title' ), - backBtn = accordionSection.find( '.customize-panel-back' ), - panelTitle = accordionSection.find( '.accordion-section-title' ).first(), - content = accordionSection.find( '.control-panel-content' ), - headerActionsHeight = $( '#customize-header-actions' ).height(); - - if ( expanded ) { + topPanel = panel.headContainer.find( '.accordion-section-title' ), + backBtn = accordionSection.find( '.customize-panel-back' ); + if ( expanded && ! accordionSection.hasClass( 'current-panel' ) ) { // Collapse any sibling sections/panels api.section.each( function ( section ) { if ( panel.id !== section.panel() ) { @@ -1359,51 +1736,42 @@ } }); - content.show( 0, function() { - content.parent().show(); - position = content.offset().top; - scroll = container.scrollTop(); - content.css( 'margin-top', ( headerActionsHeight - position - scroll ) ); - accordionSection.addClass( 'current-panel' ); - overlay.addClass( 'in-sub-panel' ); + panel._animateChangeExpanded( function() { + topPanel.attr( 'tabindex', '-1' ); + backBtn.attr( 'tabindex', '0' ); + + backBtn.focus(); + accordionSection.css( 'top', '' ); container.scrollTop( 0 ); + if ( args.completeCallback ) { args.completeCallback(); } } ); - topPanel.attr( 'tabindex', '-1' ); - backBtn.attr( 'tabindex', '0' ); - backBtn.focus(); - panel._recalculateTopMargin(); - } else { - siblings.removeClass( 'open' ); - accordionSection.removeClass( 'current-panel' ); - overlay.removeClass( 'in-sub-panel' ); - content.delay( 180 ).hide( 0, function() { - content.css( 'margin-top', 'inherit' ); // Reset + + overlay.addClass( 'in-sub-panel' ); + accordionSection.addClass( 'current-panel' ); + api.state( 'expandedPanel' ).set( panel ); + + } else if ( ! expanded && accordionSection.hasClass( 'current-panel' ) ) { + panel._animateChangeExpanded( function() { + topPanel.attr( 'tabindex', '0' ); + backBtn.attr( 'tabindex', '-1' ); + + topPanel.focus(); + accordionSection.css( 'top', '' ); + if ( args.completeCallback ) { args.completeCallback(); } } ); - topPanel.attr( 'tabindex', '0' ); - backBtn.attr( 'tabindex', '-1' ); - panelTitle.focus(); - container.scrollTop( 0 ); - } - }, - /** - * Recalculate the top margin. - * - * @since 4.4.0 - * @private - */ - _recalculateTopMargin: function() { - var panel = this, headerActionsHeight, content, accordionSection; - headerActionsHeight = $( '#customize-header-actions' ).height(); - accordionSection = panel.container.closest( '.accordion-section' ); - content = accordionSection.find( '.control-panel-content' ); - content.css( 'margin-top', ( parseInt( content.css( 'margin-top' ), 10 ) - ( content.offset().top - headerActionsHeight ) ) ); + overlay.removeClass( 'in-sub-panel' ); + accordionSection.removeClass( 'current-panel' ); + if ( panel === api.state( 'expandedPanel' ).get() ) { + api.state( 'expandedPanel' ).set( false ); + } + } }, /** @@ -1423,8 +1791,8 @@ } else { template = wp.template( 'customize-panel-default-content' ); } - if ( template && panel.container ) { - panel.container.find( '.accordion-sub-container' ).html( template( panel.params ) ); + if ( template && panel.headContainer ) { + panel.contentContainer.html( template( panel.params ) ); } } }); @@ -1474,6 +1842,7 @@ control.priority = new api.Value(); control.active = new api.Value(); control.activeArgumentsQueue = []; + control.notifications = new api.Values({ defaultConstructor: api.Notification }); control.elements = []; @@ -1521,21 +1890,63 @@ settings = $.map( control.params.settings, function( value ) { return value; }); - api.apply( api, settings.concat( function () { - var key; + if ( 0 === settings.length ) { + control.setting = null; control.settings = {}; - for ( key in control.params.settings ) { - control.settings[ key ] = api( control.params.settings[ key ] ); - } + control.embed(); + } else { + api.apply( api, settings.concat( function() { + var key; - control.setting = control.settings['default'] || null; + control.settings = {}; + for ( key in control.params.settings ) { + control.settings[ key ] = api( control.params.settings[ key ] ); + } - control.embed(); - }) ); + control.setting = control.settings['default'] || null; + + // Add setting notifications to the control notification. + _.each( control.settings, function( setting ) { + setting.notifications.bind( 'add', function( settingNotification ) { + var controlNotification, code, params; + code = setting.id + ':' + settingNotification.code; + params = _.extend( + {}, + settingNotification, + { + setting: setting.id + } + ); + controlNotification = new api.Notification( code, params ); + control.notifications.add( controlNotification.code, controlNotification ); + } ); + setting.notifications.bind( 'remove', function( settingNotification ) { + control.notifications.remove( setting.id + ':' + settingNotification.code ); + } ); + } ); + + control.embed(); + }) ); + } // After the control is embedded on the page, invoke the "ready" method. control.deferred.embedded.done( function () { + /* + * Note that this debounced/deferred rendering is needed for two reasons: + * 1) The 'remove' event is triggered just _before_ the notification is actually removed. + * 2) Improve performance when adding/removing multiple notifications at a time. + */ + var debouncedRenderNotifications = _.debounce( function renderNotifications() { + control.renderNotifications(); + } ); + control.notifications.bind( 'add', function( notification ) { + wp.a11y.speak( notification.message, 'assertive' ); + debouncedRenderNotifications(); + } ); + control.notifications.bind( 'remove', debouncedRenderNotifications ); + control.renderNotifications(); + control.ready(); }); }, @@ -1550,14 +1961,14 @@ // Watch for changes to the section state inject = function ( sectionId ) { var parentContainer; - if ( ! sectionId ) { // @todo allow a control to be embedded without a section, for instance a control embedded in the frontend + if ( ! sectionId ) { // @todo allow a control to be embedded without a section, for instance a control embedded in the front end. return; } // Wait for the section to be registered api.section( sectionId, function ( section ) { // Wait for the section to be ready/initialized section.deferred.embedded.done( function () { - parentContainer = section.container.find( 'ul:first' ); + parentContainer = ( section.contentContainer.is( 'ul' ) ) ? section.contentContainer : section.contentContainer.find( 'ul:first' ); if ( ! control.container.parent().is( parentContainer ) ) { parentContainer.append( control.container ); control.renderContent(); @@ -1573,9 +1984,107 @@ /** * Triggered when the control's markup has been injected into the DOM. * - * @abstract + * @returns {void} */ - ready: function() {}, + ready: function() { + var control = this, newItem; + if ( 'dropdown-pages' === control.params.type && control.params.allow_addition ) { + newItem = control.container.find( '.new-content-item' ); + newItem.hide(); // Hide in JS to preserve flex display when showing. + control.container.on( 'click', '.add-new-toggle', function( e ) { + $( e.currentTarget ).slideUp( 180 ); + newItem.slideDown( 180 ); + newItem.find( '.create-item-input' ).focus(); + }); + control.container.on( 'click', '.add-content', function() { + control.addNewPage(); + }); + control.container.on( 'keyup', '.create-item-input', function( e ) { + if ( 13 === e.which ) { // Enter + control.addNewPage(); + } + }); + } + }, + + /** + * Get the element inside of a control's container that contains the validation error message. + * + * Control subclasses may override this to return the proper container to render notifications into. + * Injects the notification container for existing controls that lack the necessary container, + * including special handling for nav menu items and widgets. + * + * @since 4.6.0 + * @returns {jQuery} Setting validation message element. + * @this {wp.customize.Control} + */ + getNotificationsContainerElement: function() { + var control = this, controlTitle, notificationsContainer; + + notificationsContainer = control.container.find( '.customize-control-notifications-container:first' ); + if ( notificationsContainer.length ) { + return notificationsContainer; + } + + notificationsContainer = $( '
    ' ); + + if ( control.container.hasClass( 'customize-control-nav_menu_item' ) ) { + control.container.find( '.menu-item-settings:first' ).prepend( notificationsContainer ); + } else if ( control.container.hasClass( 'customize-control-widget_form' ) ) { + control.container.find( '.widget-inside:first' ).prepend( notificationsContainer ); + } else { + controlTitle = control.container.find( '.customize-control-title' ); + if ( controlTitle.length ) { + controlTitle.after( notificationsContainer ); + } else { + control.container.prepend( notificationsContainer ); + } + } + return notificationsContainer; + }, + + /** + * Render notifications. + * + * Renders the `control.notifications` into the control's container. + * Control subclasses may override this method to do their own handling + * of rendering notifications. + * + * @since 4.6.0 + * @this {wp.customize.Control} + */ + renderNotifications: function() { + var control = this, container, notifications, hasError = false; + container = control.getNotificationsContainerElement(); + if ( ! container || ! container.length ) { + return; + } + notifications = []; + control.notifications.each( function( notification ) { + notifications.push( notification ); + if ( 'error' === notification.type ) { + hasError = true; + } + } ); + + if ( 0 === notifications.length ) { + container.stop().slideUp( 'fast' ); + } else { + container.stop().slideDown( 'fast', null, function() { + $( this ).css( 'height', 'auto' ); + } ); + } + + if ( ! control.notificationsTemplate ) { + control.notificationsTemplate = wp.template( 'customize-control-notifications' ); + } + + control.container.toggleClass( 'has-notifications', 0 !== notifications.length ); + control.container.toggleClass( 'has-error', hasError ); + container.empty().append( $.trim( + control.notificationsTemplate( { notifications: notifications, altNotice: Boolean( control.altNotice ) } ) + ) ); + }, /** * Normal controls do not expand, so just expand its parent @@ -1714,6 +2223,73 @@ control.container.html( template( control.params ) ); } } + }, + + /** + * Add a new page to a dropdown-pages control reusing menus code for this. + * + * @since 4.7.0 + * @access private + * @returns {void} + */ + addNewPage: function () { + var control = this, promise, toggle, container, input, title, select; + + if ( 'dropdown-pages' !== control.params.type || ! control.params.allow_addition || ! api.Menus ) { + return; + } + + toggle = control.container.find( '.add-new-toggle' ); + container = control.container.find( '.new-content-item' ); + input = control.container.find( '.create-item-input' ); + title = input.val(); + select = control.container.find( 'select' ); + + if ( ! title ) { + input.addClass( 'invalid' ); + return; + } + + input.removeClass( 'invalid' ); + input.attr( 'disabled', 'disabled' ); + + // The menus functions add the page, publish when appropriate, and also add the new page to the dropdown-pages controls. + promise = api.Menus.insertAutoDraftPost( { + post_title: title, + post_type: 'page' + } ); + promise.done( function( data ) { + var availableItem, $content, itemTemplate; + + // Prepare the new page as an available menu item. + // See api.Menus.submitNew(). + availableItem = new api.Menus.AvailableItemModel( { + 'id': 'post-' + data.post_id, // Used for available menu item Backbone models. + 'title': title, + 'type': 'page', + 'type_label': api.Menus.data.l10n.page_label, + 'object': 'post_type', + 'object_id': data.post_id, + 'url': data.url + } ); + + // Add the new item to the list of available menu items. + api.Menus.availableMenuItemsPanel.collection.add( availableItem ); + $content = $( '#available-menu-items-post_type-page' ).find( '.available-menu-items-list' ); + itemTemplate = wp.template( 'available-menu-item' ); + $content.prepend( itemTemplate( availableItem.attributes ) ); + + // Focus the select control. + select.focus(); + control.setting.set( String( data.post_id ) ); // Triggers a preview refresh and updates the setting. + + // Reset the create page form. + container.slideUp( 180 ); + toggle.slideDown( 180 ); + } ); + promise.always( function() { + input.val( '' ).removeAttr( 'disabled' ); + } ); } }); @@ -1727,21 +2303,57 @@ api.ColorControl = api.Control.extend({ ready: function() { var control = this, - picker = this.container.find('.color-picker-hex'); + isHueSlider = this.params.mode === 'hue', + updating = false, + picker; + + if ( isHueSlider ) { + picker = this.container.find( '.color-picker-hue' ); + picker.val( control.setting() ).wpColorPicker({ + change: function( event, ui ) { + updating = true; + control.setting( ui.color.h() ); + updating = false; + } + }); + } else { + picker = this.container.find( '.color-picker-hex' ); + picker.val( control.setting() ).wpColorPicker({ + change: function() { + updating = true; + control.setting.set( picker.wpColorPicker( 'color' ) ); + updating = false; + }, + clear: function() { + updating = true; + control.setting.set( '' ); + updating = false; + } + }); + } - picker.val( control.setting() ).wpColorPicker({ - change: function() { - control.setting.set( picker.wpColorPicker('color') ); - }, - clear: function() { - control.setting.set( '' ); + control.setting.bind( function ( value ) { + // Bail if the update came from the control itself. + if ( updating ) { + return; } - }); - - this.setting.bind( function ( value ) { picker.val( value ); picker.wpColorPicker( 'color', value ); - }); + } ); + + // Collapse color picker when hitting Esc instead of collapsing the current section. + control.container.on( 'keydown', function( event ) { + var pickerContainer; + if ( 27 !== event.which ) { // Esc. + return; + } + pickerContainer = control.container.find( '.wp-picker-container' ); + if ( pickerContainer.hasClass( 'wp-picker-active' ) ) { + picker.wpColorPicker( 'close' ); + control.container.find( '.wp-color-result' ).focus(); + event.stopPropagation(); // Prevent section from being collapsed. + } + } ); } }); @@ -1783,8 +2395,54 @@ control.pausePlayer(); }); - // Re-render whenever the control's setting changes. - control.setting.bind( function () { control.renderContent(); } ); + /** + * Set attachment data and render content. + * + * Note that BackgroundImage.prototype.ready applies this ready method + * to itself. Since BackgroundImage is an UploadControl, the value + * is the attachment URL instead of the attachment ID. In this case + * we skip fetching the attachment data because we have no ID available, + * and it is the responsibility of the UploadControl to set the control's + * attachmentData before calling the renderContent method. + * + * @param {number|string} value Attachment + */ + function setAttachmentDataAndRenderContent( value ) { + var hasAttachmentData = $.Deferred(); + + if ( control.extended( api.UploadControl ) ) { + hasAttachmentData.resolve(); + } else { + value = parseInt( value, 10 ); + if ( _.isNaN( value ) || value <= 0 ) { + delete control.params.attachment; + hasAttachmentData.resolve(); + } else if ( control.params.attachment && control.params.attachment.id === value ) { + hasAttachmentData.resolve(); + } + } + + // Fetch the attachment data. + if ( 'pending' === hasAttachmentData.state() ) { + wp.media.attachment( value ).fetch().done( function() { + control.params.attachment = this.attributes; + hasAttachmentData.resolve(); + + // Send attachment information to the preview for possible use in `postMessage` transport. + wp.customize.previewer.send( control.setting.id + '-attachment-data', this.attributes ); + } ); + } + + hasAttachmentData.done( function() { + control.renderContent(); + } ); + } + + // Ensure attachment data is initially set (for dynamically-instantiated controls). + setAttachmentDataAndRenderContent( control.setting() ); + + // Update the attachment data and re-render the control when the setting changes. + control.setting.bind( setAttachmentDataAndRenderContent ); }, pausePlayer: function () { @@ -1975,12 +2633,53 @@ wp.ajax.post( 'custom-background-add', { nonce: _wpCustomizeBackground.nonces.add, wp_customize: 'on', - theme: api.settings.theme.stylesheet, + customize_theme: api.settings.theme.stylesheet, attachment_id: this.params.attachment.id } ); } }); + /** + * A control for positioning a background image. + * + * @since 4.7.0 + * + * @class + * @augments wp.customize.Control + * @augments wp.customize.Class + */ + api.BackgroundPositionControl = api.Control.extend( { + + /** + * Set up control UI once embedded in DOM and settings are created. + * + * @since 4.7.0 + * @access public + */ + ready: function() { + var control = this, updateRadios; + + control.container.on( 'change', 'input[name="background-position"]', function() { + var position = $( this ).val().split( ' ' ); + control.settings.x( position[0] ); + control.settings.y( position[1] ); + } ); + + updateRadios = _.debounce( function() { + var x, y, radioInput, inputValue; + x = control.settings.x.get(); + y = control.settings.y.get(); + inputValue = String( x ) + ' ' + String( y ); + radioInput = control.container.find( 'input[name="background-position"][value="' + inputValue + '"]' ); + radioInput.click(); + } ); + control.settings.x.bind( updateRadios ); + control.settings.y.bind( updateRadios ); + + updateRadios(); // Set initial UI. + } + } ); + /** * A control for selecting and cropping an image. * @@ -2078,22 +2777,22 @@ xInit = parseInt( control.params.width, 10 ), yInit = parseInt( control.params.height, 10 ), ratio = xInit / yInit, - xImg = realWidth, - yImg = realHeight, + xImg = xInit, + yImg = yInit, x1, y1, imgSelectOptions; controller.set( 'canSkipCrop', ! control.mustBeCropped( flexWidth, flexHeight, xInit, yInit, realWidth, realHeight ) ); - if ( xImg / yImg > ratio ) { - yInit = yImg; + if ( realWidth / realHeight > ratio ) { + yInit = realHeight; xInit = yInit * ratio; } else { - xInit = xImg; + xInit = realWidth; yInit = xInit / ratio; } - x1 = ( xImg - xInit ) / 2; - y1 = ( yImg - yInit ) / 2; + x1 = ( realWidth - xInit ) / 2; + y1 = ( realHeight - yInit ) / 2; imgSelectOptions = { handles: true, @@ -2102,6 +2801,8 @@ persistent: true, imageWidth: realWidth, imageHeight: realHeight, + minWidth: xImg > xInit ? xInit : xImg, + minHeight: yImg > yInit ? yInit : yImg, x1: x1, y1: y1, x2: xInit + x1, @@ -2111,11 +2812,15 @@ if ( flexHeight === false && flexWidth === false ) { imgSelectOptions.aspectRatio = xInit + ':' + yInit; } - if ( flexHeight === false ) { - imgSelectOptions.maxHeight = yInit; + + if ( true === flexHeight ) { + delete imgSelectOptions.minHeight; + imgSelectOptions.maxWidth = realWidth; } - if ( flexWidth === false ) { - imgSelectOptions.maxWidth = xInit; + + if ( true === flexWidth ) { + delete imgSelectOptions.minWidth; + imgSelectOptions.maxHeight = realHeight; } return imgSelectOptions; @@ -2246,7 +2951,7 @@ controller.setImageFromAttachment( croppedImage ); controller.frame.close(); } ).fail( function() { - controller.trigger('content:error:crop'); + controller.frame.trigger('content:error:crop'); } ); } else { this.frame.setState( 'cropper' ); @@ -2259,7 +2964,7 @@ * @param {object} attachment */ setImageFromAttachment: function( attachment ) { - var sizes = [ 'site_icon-32', 'thumbnail', 'full' ], + var sizes = [ 'site_icon-32', 'thumbnail', 'full' ], link, icon; _.each( sizes, function( size ) { @@ -2273,8 +2978,13 @@ // Set the Customizer setting; the callback takes care of rendering. this.setting( attachment.id ); + if ( ! icon ) { + return; + } + // Update the icon in-browser. - $( 'link[sizes="32x32"]' ).attr( 'href', icon.url ); + link = $( 'link[rel="icon"][sizes="32x32"]' ); + link.attr( 'href', icon.url ); }, /** @@ -2291,7 +3001,7 @@ this.params.attachment = {}; this.setting( '' ); this.renderContent(); // Not bound to setting change when emptying. - $( 'link[rel="icon"]' ).attr( 'href', '' ); + $( 'link[rel="icon"][sizes="32x32"]' ).attr( 'href', '/favicon.ico' ); // Set to default. } }); @@ -2331,6 +3041,10 @@ api.HeaderTool.UploadsList, api.HeaderTool.DefaultsList ]); + + // Ensure custom-header-crop Ajax requests bootstrap the Customizer to activate the previewed theme. + wp.media.controller.Cropper.prototype.defaults.doCropArgs.wp_customize = 'on'; + wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet; }, /** @@ -2621,11 +3335,7 @@ return; } - var previewUrl = $( this ).data( 'previewUrl' ); - - $( '.wp-full-overlay' ).addClass( 'customize-loading' ); - - window.parent.location = previewUrl; + api.section( control.section() ).loadThemePreview( control.params.theme.id ); }); control.container.on( 'click keydown', '.theme-actions .theme-details', function( event ) { @@ -2686,13 +3396,12 @@ * @mixes wp.customize.Events */ api.PreviewFrame = api.Messenger.extend({ - sensitivity: 2000, + sensitivity: null, // Will get set to api.settings.timeouts.previewFrameSensitivity. /** * Initialize the PreviewFrame. * * @param {object} params.container - * @param {object} params.signature * @param {object} params.previewUrl * @param {object} params.query * @param {object} options @@ -2707,7 +3416,6 @@ deferred.promise( this ); this.container = params.container; - this.signature = params.signature; $.extend( params, { channel: api.PreviewFrame.uuid() }); @@ -2727,122 +3435,118 @@ * the request. */ run: function( deferred ) { - var self = this, + var previewFrame = this, loaded = false, - ready = false; - - if ( this._ready ) { - this.unbind( 'ready', this._ready ); + ready = false, + readyData = null, + hasPendingChangesetUpdate = '{}' !== previewFrame.query.customized, + urlParser, + params, + form; + + if ( previewFrame._ready ) { + previewFrame.unbind( 'ready', previewFrame._ready ); } - this._ready = function() { + previewFrame._ready = function( data ) { ready = true; + readyData = data; + previewFrame.container.addClass( 'iframe-ready' ); + if ( ! data ) { + return; + } if ( loaded ) { - deferred.resolveWith( self ); + deferred.resolveWith( previewFrame, [ data ] ); } }; - this.bind( 'ready', this._ready ); + previewFrame.bind( 'ready', previewFrame._ready ); - this.bind( 'ready', function ( data ) { + urlParser = document.createElement( 'a' ); + urlParser.href = previewFrame.previewUrl(); - this.container.addClass( 'iframe-ready' ); - - if ( ! data ) { - return; + params = _.extend( + api.utils.parseQueryString( urlParser.search.substr( 1 ) ), + { + customize_changeset_uuid: previewFrame.query.customize_changeset_uuid, + customize_theme: previewFrame.query.customize_theme, + customize_messenger_channel: previewFrame.query.customize_messenger_channel } + ); - /* - * Walk over all panels, sections, and controls and set their - * respective active states to true if the preview explicitly - * indicates as such. - */ - var constructs = { - panel: data.activePanels, - section: data.activeSections, - control: data.activeControls - }; - _( constructs ).each( function ( activeConstructs, type ) { - api[ type ].each( function ( construct, id ) { - var active = !! ( activeConstructs && activeConstructs[ id ] ); - if ( active ) { - construct.activate(); - } else { - construct.deactivate(); - } - } ); - } ); + urlParser.search = $.param( params ); + previewFrame.iframe = $( '