X-Git-Url: https://scripts.mit.edu/gitweb/autoinstalls/wordpress.git/blobdiff_plain/607b7e02d77e7326161e8ec15639052d2040f745..0f74cdeda4c069bfbb9c4131ef1352f55b6f8499:/wp-admin/js/customize-controls.js diff --git a/wp-admin/js/customize-controls.js b/wp-admin/js/customize-controls.js index 8fa7bb09..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,26 +22,45 @@ */ 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; - this.notifications = new api.Values({ defaultConstructor: api.Notification }); + 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(); } }, @@ -65,9 +84,172 @@ }); /** - * 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 @@ -101,10 +283,8 @@ 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; } @@ -125,6 +305,8 @@ } else { params.completeCallback = focus; } + + api.state( 'paneVisible' ).set( true ); if ( construct.expand ) { construct.expand( params ); } else { @@ -185,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. * @@ -236,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() @@ -323,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(); @@ -350,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(); - } }, /** @@ -441,6 +650,7 @@ return false; } + api.state( 'paneVisible' ).set( true ); params.completeCallback = function() { if ( previousCompleteCallback ) { previousCompleteCallback.apply( instance, arguments ); @@ -479,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] @@ -504,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' ) + } ); } }); @@ -549,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' ] ); @@ -566,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 ) { @@ -576,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 ) ); - }); }, /** @@ -609,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 ) { @@ -624,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'; + }); + }); }, /** @@ -666,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 ); - setTimeout( _.bind( section._recalculateTopMargin, section ), 0 ); - }; + if ( args.completeCallback ) { + args.completeCallback(); + } + } ); + + content.addClass( 'open' ); + overlay.addClass( 'section-open' ); + api.state( 'expandedSection' ).set( section ); + }, this ); } if ( ! args.allowMultiple ) { @@ -732,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 ) ); - } } }); @@ -805,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; } @@ -823,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. } }); @@ -945,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 ) { @@ -967,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' ); + } }, /** @@ -1134,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. * @@ -1142,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 ) ) @@ -1151,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(); }, @@ -1234,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(); }, @@ -1255,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; } @@ -1286,7 +1647,6 @@ } event.preventDefault(); // Keep this AFTER the key filter above - meta = panel.container.find( '.panel-meta' ); if ( meta.hasClass( 'cannot-expand' ) ) { return; } @@ -1356,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() ) { @@ -1382,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 ); + } + } }, /** @@ -1446,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 ) ); } } }); @@ -1623,7 +1968,7 @@ 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(); @@ -1639,9 +1984,28 @@ /** * 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. @@ -1859,34 +2223,137 @@ control.container.html( template( control.params ) ); } } - } - }); - - /** - * A colorpicker control. - * - * @class - * @augments wp.customize.Control - * @augments wp.customize.Class - */ - api.ColorControl = api.Control.extend({ - ready: function() { - var control = this, - picker = this.container.find('.color-picker-hex'); + }, - picker.val( control.setting() ).wpColorPicker({ - change: function() { - control.setting.set( picker.wpColorPicker('color') ); - }, - clear: function() { - control.setting.set( '' ); - } - }); + /** + * 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' ); + } ); + } + }); + + /** + * A colorpicker control. + * + * @class + * @augments wp.customize.Control + * @augments wp.customize.Class + */ + api.ColorControl = api.Control.extend({ + ready: function() { + var control = this, + 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; + } + }); + } - this.setting.bind( function ( value ) { + control.setting.bind( function ( value ) { + // Bail if the update came from the control itself. + if ( updating ) { + return; + } 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. + } + } ); } }); @@ -2166,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. * @@ -2456,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 ) { @@ -2470,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 ); }, /** @@ -2488,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. } }); @@ -2531,7 +3044,7 @@ // 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.theme = api.settings.theme.stylesheet; + wp.media.controller.Cropper.prototype.defaults.doCropArgs.customize_theme = api.settings.theme.stylesheet; }, /** @@ -2822,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 ) { @@ -2887,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 @@ -2908,7 +3416,6 @@ deferred.promise( this ); this.container = params.container; - this.signature = params.signature; $.extend( params, { channel: api.PreviewFrame.uuid() }); @@ -2928,129 +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(); - } - } ); - } ); - - if ( data.settingValidities ) { - api._handleSettingValidities( { - settingValidities: data.settingValidities, - focusInvalidControl: false - } ); - } + urlParser.search = $.param( params ); + previewFrame.iframe = $( '